@ternkit/tern 1.3.0 → 1.5.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/deferred.d.ts +5 -1
- package/dist/runtime/deferred.js +27 -11
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.js +168 -0
- package/dist/server/intl.js +55 -12
- package/dist/ui/AlertDialog.svelte +79 -0
- package/dist/ui/AlertDialog.svelte.d.ts +24 -0
- package/dist/ui/AreaChart.svelte +9 -0
- package/dist/ui/AreaChart.svelte.d.ts +26 -0
- package/dist/ui/AspectRatio.svelte +33 -0
- package/dist/ui/AspectRatio.svelte.d.ts +10 -0
- package/dist/ui/BarChart.svelte +170 -0
- package/dist/ui/BarChart.svelte.d.ts +40 -0
- package/dist/ui/Breadcrumb.svelte +92 -0
- package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
- package/dist/ui/ButtonGroup.svelte +61 -0
- package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
- package/dist/ui/Calendar.svelte +265 -0
- package/dist/ui/Calendar.svelte.d.ts +28 -0
- package/dist/ui/Carousel.svelte +169 -0
- package/dist/ui/Carousel.svelte.d.ts +36 -0
- package/dist/ui/ChartFrame.svelte +174 -0
- package/dist/ui/ChartFrame.svelte.d.ts +24 -0
- package/dist/ui/Checkbox.svelte +13 -1
- package/dist/ui/Collapsible.svelte +59 -0
- package/dist/ui/Collapsible.svelte.d.ts +12 -0
- package/dist/ui/Combobox.svelte +350 -0
- package/dist/ui/Combobox.svelte.d.ts +24 -0
- package/dist/ui/Command.svelte +268 -0
- package/dist/ui/Command.svelte.d.ts +28 -0
- package/dist/ui/ConsentScreen.svelte +125 -0
- package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
- package/dist/ui/ContextMenu.svelte +78 -0
- package/dist/ui/ContextMenu.svelte.d.ts +13 -0
- package/dist/ui/DataTable.svelte +277 -0
- package/dist/ui/DataTable.svelte.d.ts +53 -0
- package/dist/ui/DatePicker.svelte +138 -0
- package/dist/ui/DatePicker.svelte.d.ts +25 -0
- package/dist/ui/Dialog.svelte +168 -27
- package/dist/ui/Dialog.svelte.d.ts +10 -0
- package/dist/ui/DonutChart.svelte +115 -0
- package/dist/ui/DonutChart.svelte.d.ts +40 -0
- package/dist/ui/Drawer.svelte +19 -0
- package/dist/ui/Drawer.svelte.d.ts +14 -0
- package/dist/ui/Empty.svelte +62 -0
- package/dist/ui/Empty.svelte.d.ts +15 -0
- package/dist/ui/Field.svelte +22 -2
- package/dist/ui/Field.svelte.d.ts +2 -0
- package/dist/ui/Form.svelte +159 -0
- package/dist/ui/Form.svelte.d.ts +21 -0
- package/dist/ui/HoverCard.svelte +106 -0
- package/dist/ui/HoverCard.svelte.d.ts +26 -0
- package/dist/ui/Input.svelte +2 -1
- package/dist/ui/InputGroup.svelte +80 -0
- package/dist/ui/InputGroup.svelte.d.ts +13 -0
- package/dist/ui/InputOTP.svelte +140 -0
- package/dist/ui/InputOTP.svelte.d.ts +18 -0
- package/dist/ui/Item.svelte +86 -0
- package/dist/ui/Item.svelte.d.ts +17 -0
- package/dist/ui/Label.svelte +29 -0
- package/dist/ui/Label.svelte.d.ts +10 -0
- package/dist/ui/LineChart.svelte +171 -0
- package/dist/ui/LineChart.svelte.d.ts +42 -0
- package/dist/ui/Menu.svelte +22 -151
- package/dist/ui/Menu.svelte.d.ts +3 -10
- package/dist/ui/MenuList.svelte +317 -0
- package/dist/ui/MenuList.svelte.d.ts +29 -0
- package/dist/ui/Menubar.svelte +133 -0
- package/dist/ui/Menubar.svelte.d.ts +14 -0
- package/dist/ui/NavigationMenu.svelte +208 -0
- package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
- package/dist/ui/Pagination.svelte +114 -0
- package/dist/ui/Pagination.svelte.d.ts +16 -0
- package/dist/ui/PasswordInput.svelte +134 -0
- package/dist/ui/PasswordInput.svelte.d.ts +11 -0
- package/dist/ui/Popover.svelte +127 -0
- package/dist/ui/Popover.svelte.d.ts +29 -0
- package/dist/ui/Resizable.svelte +122 -0
- package/dist/ui/Resizable.svelte.d.ts +16 -0
- package/dist/ui/ScrollArea.svelte +43 -0
- package/dist/ui/ScrollArea.svelte.d.ts +13 -0
- package/dist/ui/Select.svelte +3 -2
- package/dist/ui/SelectMenu.svelte +301 -0
- package/dist/ui/SelectMenu.svelte.d.ts +18 -0
- package/dist/ui/Sheet.svelte +21 -0
- package/dist/ui/Sheet.svelte.d.ts +16 -0
- package/dist/ui/Sidebar.svelte +263 -0
- package/dist/ui/Sidebar.svelte.d.ts +35 -0
- package/dist/ui/SignIn.svelte +182 -0
- package/dist/ui/SignIn.svelte.d.ts +38 -0
- package/dist/ui/SignUp.svelte +106 -0
- package/dist/ui/SignUp.svelte.d.ts +23 -0
- package/dist/ui/Slider.svelte +196 -0
- package/dist/ui/Slider.svelte.d.ts +19 -0
- package/dist/ui/Sparkline.svelte +23 -0
- package/dist/ui/Sparkline.svelte.d.ts +11 -0
- package/dist/ui/Stat.svelte +91 -0
- package/dist/ui/Stat.svelte.d.ts +18 -0
- package/dist/ui/Table.svelte +70 -0
- package/dist/ui/Table.svelte.d.ts +36 -0
- package/dist/ui/Textarea.svelte +2 -1
- package/dist/ui/Toggle.svelte +82 -0
- package/dist/ui/Toggle.svelte.d.ts +17 -0
- package/dist/ui/ToggleGroup.svelte +143 -0
- package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
- package/dist/ui/Tooltip.svelte +27 -19
- package/dist/ui/Tooltip.svelte.d.ts +2 -1
- package/dist/ui/chart.d.ts +26 -0
- package/dist/ui/chart.js +89 -0
- package/dist/ui/dates.d.ts +13 -0
- package/dist/ui/dates.js +64 -0
- package/dist/ui/field.d.ts +8 -0
- package/dist/ui/floating.d.ts +44 -0
- package/dist/ui/floating.js +97 -0
- package/dist/ui/form.d.ts +12 -0
- package/dist/ui/form.js +8 -0
- package/dist/ui/index.d.ts +54 -0
- package/dist/ui/index.js +43 -0
- package/dist/ui/menu.d.ts +25 -0
- package/dist/ui/menu.js +1 -0
- package/dist/ui/options.d.ts +17 -0
- package/dist/ui/options.js +22 -0
- package/dist/ui/table.d.ts +26 -0
- package/dist/ui/table.js +1 -0
- package/dist/ui/theme.css +25 -0
- package/dist/vite/index.js +12 -3
- package/dist/vite/native.d.ts +3 -0
- package/dist/vite/native.js +122 -0
- package/package.json +3 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Props = {
|
|
3
|
+
title: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
/** The heading level, to fit the page's outline. */
|
|
6
|
+
level?: 2 | 3 | 4;
|
|
7
|
+
/** An illustration or icon above the title. */
|
|
8
|
+
icon?: Snippet;
|
|
9
|
+
/** What to do next, such as a Create button. */
|
|
10
|
+
actions?: Snippet;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const Empty: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type Empty = ReturnType<typeof Empty>;
|
|
15
|
+
export default Empty;
|
package/dist/ui/Field.svelte
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { setField } from './field.js';
|
|
4
|
+
import { getForm } from './form.js';
|
|
4
5
|
|
|
5
6
|
type Props = {
|
|
6
7
|
/** The visible label. */
|
|
@@ -11,16 +12,24 @@
|
|
|
11
12
|
error?: string | null;
|
|
12
13
|
required?: boolean;
|
|
13
14
|
disabled?: boolean;
|
|
15
|
+
/** The control's name. Inside a Form, the Field shows that field's error and knows if it's required, and the control posts under it. */
|
|
16
|
+
name?: string;
|
|
14
17
|
class?: string;
|
|
15
18
|
/** The control, such as an Input, Textarea or Select. */
|
|
16
19
|
children: Snippet;
|
|
17
20
|
};
|
|
18
|
-
let { label, description, error = null, required = false, disabled = false, class: klass = '', children }: Props = $props();
|
|
21
|
+
let { label, description, error: errorProp = null, required: requiredProp = false, disabled = false, name, class: klass = '', children }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const form = getForm();
|
|
24
|
+
const error = $derived(errorProp ?? (name ? form?.error(name) : undefined) ?? null);
|
|
25
|
+
const required = $derived(requiredProp || (name ? !!form?.field(name)?.required : false));
|
|
19
26
|
|
|
20
27
|
const uid = $props.id();
|
|
28
|
+
let custom = $state(false);
|
|
21
29
|
const id = `tern-field-${uid}`;
|
|
22
30
|
setField({
|
|
23
31
|
id,
|
|
32
|
+
labelId: `${id}-label`,
|
|
24
33
|
get describedBy() {
|
|
25
34
|
return [description ? `${id}-description` : '', error ? `${id}-error` : ''].filter(Boolean).join(' ') || undefined;
|
|
26
35
|
},
|
|
@@ -32,12 +41,22 @@
|
|
|
32
41
|
},
|
|
33
42
|
get disabled() {
|
|
34
43
|
return disabled;
|
|
44
|
+
},
|
|
45
|
+
custom() {
|
|
46
|
+
custom = true;
|
|
47
|
+
},
|
|
48
|
+
get name() {
|
|
49
|
+
return name;
|
|
50
|
+
},
|
|
51
|
+
get value() {
|
|
52
|
+
return name && !form?.field(name)?.secret ? form?.value(name) : undefined;
|
|
35
53
|
}
|
|
36
54
|
});
|
|
37
55
|
</script>
|
|
38
56
|
|
|
39
57
|
<div class="tern-field {klass}" class:disabled>
|
|
40
|
-
|
|
58
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
59
|
+
<label for={custom ? undefined : id} id="{id}-label" onclick={custom ? () => document.getElementById(id)?.focus() : undefined}>
|
|
41
60
|
{label}
|
|
42
61
|
{#if required}<span class="required" aria-hidden="true">*</span>{/if}
|
|
43
62
|
</label>
|
|
@@ -49,6 +68,7 @@
|
|
|
49
68
|
<style>
|
|
50
69
|
.tern-field {
|
|
51
70
|
display: grid;
|
|
71
|
+
align-content: start;
|
|
52
72
|
gap: 0.375rem;
|
|
53
73
|
}
|
|
54
74
|
label {
|
|
@@ -8,6 +8,8 @@ type Props = {
|
|
|
8
8
|
error?: string | null;
|
|
9
9
|
required?: boolean;
|
|
10
10
|
disabled?: boolean;
|
|
11
|
+
/** The control's name. Inside a Form, the Field shows that field's error and knows if it's required, and the control posts under it. */
|
|
12
|
+
name?: string;
|
|
11
13
|
class?: string;
|
|
12
14
|
/** The control, such as an Input, Textarea or Select. */
|
|
13
15
|
children: Snippet;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick, type Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
4
|
+
import { enhance as enhanceAction } from '../runtime/forms.js';
|
|
5
|
+
import { validate, type FormField } from '../runtime/validate.js';
|
|
6
|
+
import Alert from './Alert.svelte';
|
|
7
|
+
import { setForm } from './form.js';
|
|
8
|
+
|
|
9
|
+
type Failure = { errors?: Record<string, string>; values?: Record<string, unknown>; message?: string };
|
|
10
|
+
|
|
11
|
+
type Props = Omit<HTMLFormAttributes, 'onsubmit'> & {
|
|
12
|
+
/** The action's rules, from ./$forms: forms.create. The browser checks them as the user types and before posting. */
|
|
13
|
+
rules?: FormField[];
|
|
14
|
+
/** The page's form prop, so the server's errors and the posted values show up. */
|
|
15
|
+
form?: unknown;
|
|
16
|
+
/** Post in the background with use:enhance. Without it, or without JavaScript, the browser posts the form. */
|
|
17
|
+
enhance?: boolean;
|
|
18
|
+
/** Whether a post is in flight; bind it to show a spinner. */
|
|
19
|
+
submitting?: boolean;
|
|
20
|
+
/** Clear the form after a successful post. */
|
|
21
|
+
reset?: boolean;
|
|
22
|
+
/** Runs after a successful post with the action's result. */
|
|
23
|
+
onsuccess?: (data: unknown) => void;
|
|
24
|
+
children: Snippet;
|
|
25
|
+
};
|
|
26
|
+
let { rules = [], form, enhance = true, submitting = $bindable(false), reset = true, onsuccess, method = 'POST', children, class: klass = '', ...rest }: Props = $props();
|
|
27
|
+
|
|
28
|
+
const uid = $props.id();
|
|
29
|
+
let el = $state<HTMLFormElement>();
|
|
30
|
+
let client = $state<Record<string, string>>({});
|
|
31
|
+
let touched = $state<Record<string, boolean>>({});
|
|
32
|
+
let edited = $state<Record<string, boolean>>({});
|
|
33
|
+
let submitted = $state(false);
|
|
34
|
+
let hydrated = $state(false);
|
|
35
|
+
let summary = $state('');
|
|
36
|
+
|
|
37
|
+
const failure = $derived((form && typeof form === 'object' && 'errors' in form ? form : null) as Failure | null);
|
|
38
|
+
// A new answer from the server replaces what the user had edited since the last one.
|
|
39
|
+
$effect(() => {
|
|
40
|
+
void failure;
|
|
41
|
+
edited = {};
|
|
42
|
+
});
|
|
43
|
+
$effect(() => {
|
|
44
|
+
hydrated = true;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const byName = $derived(new Map(rules.map((f) => [f.name, f])));
|
|
48
|
+
setForm({
|
|
49
|
+
error: (name) => client[name] ?? (edited[name] ? undefined : failure?.errors?.[name]),
|
|
50
|
+
value: (name) => {
|
|
51
|
+
const v = failure?.values?.[name];
|
|
52
|
+
return typeof v === 'string' ? v : undefined;
|
|
53
|
+
},
|
|
54
|
+
field: (name) => byName.get(name)
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function check(names: string[]) {
|
|
58
|
+
if (!el || rules.length === 0) return;
|
|
59
|
+
const all = validate(rules, new FormData(el));
|
|
60
|
+
const next = { ...client };
|
|
61
|
+
for (const n of names) {
|
|
62
|
+
if (all[n]) next[n] = all[n]!;
|
|
63
|
+
else delete next[n];
|
|
64
|
+
}
|
|
65
|
+
client = next;
|
|
66
|
+
}
|
|
67
|
+
// A field and the fields that must match it, such as a password and its confirmation.
|
|
68
|
+
const related = (name: string) => [name, ...rules.filter((f) => f.rules?.some((r) => r.name === 'same' && r.arg === name)).map((f) => f.name)];
|
|
69
|
+
|
|
70
|
+
function oninput(e: Event) {
|
|
71
|
+
const name = (e.target as HTMLInputElement | null)?.name;
|
|
72
|
+
if (!name) return;
|
|
73
|
+
edited = { ...edited, [name]: true };
|
|
74
|
+
if (touched[name] || submitted) check(related(name).filter((n) => touched[n] || submitted || n === name));
|
|
75
|
+
}
|
|
76
|
+
function onfocusout(e: FocusEvent) {
|
|
77
|
+
const name = (e.target as HTMLInputElement | null)?.name;
|
|
78
|
+
// Don't complain about a field the user only passed through.
|
|
79
|
+
if (!name || !byName.has(name) || (!edited[name] && !submitted)) return;
|
|
80
|
+
touched = { ...touched, [name]: true };
|
|
81
|
+
check([name]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Checks everything; returns false and focuses the first problem if anything is wrong. */
|
|
85
|
+
function checkAll(data: FormData) {
|
|
86
|
+
submitted = true;
|
|
87
|
+
const errors = validate(rules, data) as Record<string, string>;
|
|
88
|
+
client = errors;
|
|
89
|
+
const names = Object.keys(errors);
|
|
90
|
+
if (names.length === 0) {
|
|
91
|
+
summary = '';
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
summary = names.length === 1 ? '1 field needs attention.' : `${names.length} fields need attention.`;
|
|
95
|
+
void tick().then(() => el?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus());
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const submit = ({ formData, cancel }: { formData: FormData; cancel(): void }) => {
|
|
100
|
+
const errors = validate(rules, formData);
|
|
101
|
+
if (Object.keys(errors).length > 0) {
|
|
102
|
+
cancel();
|
|
103
|
+
checkAll(formData);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
submitted = true;
|
|
107
|
+
client = {};
|
|
108
|
+
summary = '';
|
|
109
|
+
submitting = true;
|
|
110
|
+
return async ({ update, result }: { update: (o?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: unknown } }) => {
|
|
111
|
+
await update({ reset });
|
|
112
|
+
submitting = false;
|
|
113
|
+
if (result.type === 'success') {
|
|
114
|
+
submitted = false;
|
|
115
|
+
touched = {};
|
|
116
|
+
onsuccess?.(result.data);
|
|
117
|
+
} else if (result.type === 'failure') {
|
|
118
|
+
await tick();
|
|
119
|
+
el?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function attach(node: HTMLFormElement) {
|
|
125
|
+
if (!enhance) return;
|
|
126
|
+
const action = enhanceAction(node, submit as never);
|
|
127
|
+
return () => action?.destroy?.();
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<form
|
|
132
|
+
bind:this={el}
|
|
133
|
+
class="tern-form {klass}"
|
|
134
|
+
{method}
|
|
135
|
+
novalidate={hydrated && rules.length > 0}
|
|
136
|
+
aria-describedby={failure?.message ? `tern-form-${uid}-message` : undefined}
|
|
137
|
+
{...rest}
|
|
138
|
+
{oninput}
|
|
139
|
+
onchange={oninput}
|
|
140
|
+
{onfocusout}
|
|
141
|
+
onsubmit={(e) => {
|
|
142
|
+
if (enhance) return; // use:enhance checks first
|
|
143
|
+
if (!checkAll(new FormData(e.currentTarget))) e.preventDefault();
|
|
144
|
+
}}
|
|
145
|
+
{@attach attach}
|
|
146
|
+
>
|
|
147
|
+
{#if failure?.message}
|
|
148
|
+
<div id="tern-form-{uid}-message"><Alert tone="danger" live>{failure.message}</Alert></div>
|
|
149
|
+
{/if}
|
|
150
|
+
{@render children()}
|
|
151
|
+
<p class="tern-sr-only" role="status">{summary}</p>
|
|
152
|
+
</form>
|
|
153
|
+
|
|
154
|
+
<style>
|
|
155
|
+
.tern-form {
|
|
156
|
+
display: grid;
|
|
157
|
+
gap: 1rem;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
3
|
+
import { type FormField } from '../runtime/validate.js';
|
|
4
|
+
type Props = Omit<HTMLFormAttributes, 'onsubmit'> & {
|
|
5
|
+
/** The action's rules, from ./$forms: forms.create. The browser checks them as the user types and before posting. */
|
|
6
|
+
rules?: FormField[];
|
|
7
|
+
/** The page's form prop, so the server's errors and the posted values show up. */
|
|
8
|
+
form?: unknown;
|
|
9
|
+
/** Post in the background with use:enhance. Without it, or without JavaScript, the browser posts the form. */
|
|
10
|
+
enhance?: boolean;
|
|
11
|
+
/** Whether a post is in flight; bind it to show a spinner. */
|
|
12
|
+
submitting?: boolean;
|
|
13
|
+
/** Clear the form after a successful post. */
|
|
14
|
+
reset?: boolean;
|
|
15
|
+
/** Runs after a successful post with the action's result. */
|
|
16
|
+
onsuccess?: (data: unknown) => void;
|
|
17
|
+
children: Snippet;
|
|
18
|
+
};
|
|
19
|
+
declare const Form: import("svelte").Component<Props, {}, "submitting">;
|
|
20
|
+
type Form = ReturnType<typeof Form>;
|
|
21
|
+
export default Form;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { float, type Align, type Side } from './floating.js';
|
|
4
|
+
|
|
5
|
+
export type HoverCardTrigger = {
|
|
6
|
+
'aria-describedby'?: string;
|
|
7
|
+
onpointerenter: () => void;
|
|
8
|
+
onpointerleave: () => void;
|
|
9
|
+
onfocus: () => void;
|
|
10
|
+
onblur: () => void;
|
|
11
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
/** The link or button it previews. Spread the props onto it. */
|
|
16
|
+
trigger: Snippet<[HoverCardTrigger]>;
|
|
17
|
+
/** The preview. Pointer and keyboard users see it; it is also linked as the trigger's description, so keep it short or make sure what it shows is reachable elsewhere. */
|
|
18
|
+
children: Snippet;
|
|
19
|
+
side?: Side;
|
|
20
|
+
align?: Align;
|
|
21
|
+
/** Milliseconds before it opens. */
|
|
22
|
+
openDelay?: number;
|
|
23
|
+
/** Milliseconds before it closes once the pointer leaves. */
|
|
24
|
+
closeDelay?: number;
|
|
25
|
+
class?: string;
|
|
26
|
+
};
|
|
27
|
+
let { trigger, children, side = 'bottom', align = 'center', openDelay = 500, closeDelay = 200, class: klass = '' }: Props = $props();
|
|
28
|
+
|
|
29
|
+
const uid = $props.id();
|
|
30
|
+
const id = `tern-hovercard-${uid}`;
|
|
31
|
+
let open = $state(false);
|
|
32
|
+
let card = $state<HTMLDivElement>();
|
|
33
|
+
let anchorEl: Element | null = null;
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
|
|
36
|
+
function later(fn: () => void, ms: number) {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
timer = setTimeout(fn, ms);
|
|
39
|
+
}
|
|
40
|
+
const show = (now = false) => {
|
|
41
|
+
anchorEl = document.activeElement?.matches(`[aria-describedby~="${id}"]`) ? document.activeElement : (document.querySelector(`[aria-describedby~="${id}"]`) ?? anchorEl);
|
|
42
|
+
if (now) {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
open = true;
|
|
45
|
+
} else later(() => (open = true), openDelay);
|
|
46
|
+
};
|
|
47
|
+
const hide = (now = false) => (now ? ((open = false), clearTimeout(timer)) : later(() => (open = false), closeDelay));
|
|
48
|
+
|
|
49
|
+
$effect(() => {
|
|
50
|
+
if (!open || !card || !anchorEl) return;
|
|
51
|
+
card.showPopover?.();
|
|
52
|
+
return float(card, anchorEl, { side, align, offset: 8 });
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
{@render trigger({
|
|
57
|
+
'aria-describedby': id,
|
|
58
|
+
onpointerenter: () => show(),
|
|
59
|
+
onpointerleave: () => hide(),
|
|
60
|
+
onfocus: () => show(true),
|
|
61
|
+
onblur: () => hide(true),
|
|
62
|
+
onkeydown: (e) => e.key === 'Escape' && hide(true)
|
|
63
|
+
})}
|
|
64
|
+
<div
|
|
65
|
+
bind:this={card}
|
|
66
|
+
{id}
|
|
67
|
+
class="tern-hovercard {klass}"
|
|
68
|
+
class:open
|
|
69
|
+
popover="manual"
|
|
70
|
+
role="none"
|
|
71
|
+
onpointerenter={() => clearTimeout(timer)}
|
|
72
|
+
onpointerleave={() => hide()}
|
|
73
|
+
>
|
|
74
|
+
{#if open}{@render children()}{/if}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
.tern-hovercard {
|
|
79
|
+
width: max-content;
|
|
80
|
+
max-width: min(20rem, calc(100vw - 1rem));
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
color: var(--tern-fg);
|
|
83
|
+
background: var(--tern-surface);
|
|
84
|
+
border: 1px solid var(--tern-border);
|
|
85
|
+
border-radius: var(--tern-radius);
|
|
86
|
+
box-shadow: var(--tern-shadow-lg);
|
|
87
|
+
font-size: 0.875rem;
|
|
88
|
+
}
|
|
89
|
+
.tern-hovercard:not(.open) {
|
|
90
|
+
display: none;
|
|
91
|
+
}
|
|
92
|
+
.open {
|
|
93
|
+
animation: tern-hover-in var(--tern-duration) ease-out;
|
|
94
|
+
}
|
|
95
|
+
@keyframes tern-hover-in {
|
|
96
|
+
from {
|
|
97
|
+
opacity: 0;
|
|
98
|
+
transform: translateY(2px);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
@media (prefers-reduced-motion: reduce) {
|
|
102
|
+
.open {
|
|
103
|
+
animation: none;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type Align, type Side } from './floating.js';
|
|
3
|
+
export type HoverCardTrigger = {
|
|
4
|
+
'aria-describedby'?: string;
|
|
5
|
+
onpointerenter: () => void;
|
|
6
|
+
onpointerleave: () => void;
|
|
7
|
+
onfocus: () => void;
|
|
8
|
+
onblur: () => void;
|
|
9
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
10
|
+
};
|
|
11
|
+
type Props = {
|
|
12
|
+
/** The link or button it previews. Spread the props onto it. */
|
|
13
|
+
trigger: Snippet<[HoverCardTrigger]>;
|
|
14
|
+
/** The preview. Pointer and keyboard users see it; it is also linked as the trigger's description, so keep it short or make sure what it shows is reachable elsewhere. */
|
|
15
|
+
children: Snippet;
|
|
16
|
+
side?: Side;
|
|
17
|
+
align?: Align;
|
|
18
|
+
/** Milliseconds before it opens. */
|
|
19
|
+
openDelay?: number;
|
|
20
|
+
/** Milliseconds before it closes once the pointer leaves. */
|
|
21
|
+
closeDelay?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
};
|
|
24
|
+
declare const HoverCard: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type HoverCard = ReturnType<typeof HoverCard>;
|
|
26
|
+
export default HoverCard;
|
package/dist/ui/Input.svelte
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { getField } from './field.js';
|
|
4
4
|
|
|
5
5
|
type Props = HTMLInputAttributes & { value?: unknown };
|
|
6
|
-
let { value = $bindable(), class: klass = '', ...rest }: Props = $props();
|
|
7
6
|
const field = getField();
|
|
7
|
+
let { value = $bindable(field?.value), class: klass = '', ...rest }: Props = $props();
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<input
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
aria-invalid={field?.invalid || undefined}
|
|
15
15
|
required={field?.required}
|
|
16
16
|
disabled={field?.disabled}
|
|
17
|
+
name={field?.name}
|
|
17
18
|
{...rest}
|
|
18
19
|
bind:value
|
|
19
20
|
/>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** Before the input: text such as "https://", an icon, or a button. */
|
|
6
|
+
start?: Snippet;
|
|
7
|
+
/** After the input: text such as ".com", a unit, or a button. */
|
|
8
|
+
end?: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
/** The Input. */
|
|
11
|
+
children: Snippet;
|
|
12
|
+
};
|
|
13
|
+
let { start, end, class: klass = '', children }: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<!-- An Input with text, icons or buttons inside its border. Text in start and end is read with the label when you add it to the Field's description, not on its own. -->
|
|
17
|
+
<div class="tern-input-group {klass}">
|
|
18
|
+
{#if start}<span class="addon start">{@render start()}</span>{/if}
|
|
19
|
+
{@render children()}
|
|
20
|
+
{#if end}<span class="addon end">{@render end()}</span>{/if}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.tern-input-group {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
height: var(--tern-control-height);
|
|
28
|
+
background: var(--tern-surface);
|
|
29
|
+
border: 1px solid var(--tern-border-strong);
|
|
30
|
+
border-radius: var(--tern-radius-sm);
|
|
31
|
+
transition:
|
|
32
|
+
border-color var(--tern-duration),
|
|
33
|
+
box-shadow var(--tern-duration);
|
|
34
|
+
}
|
|
35
|
+
.tern-input-group:hover {
|
|
36
|
+
border-color: var(--tern-muted);
|
|
37
|
+
}
|
|
38
|
+
.tern-input-group:has(:global(input:focus-visible)) {
|
|
39
|
+
border-color: var(--tern-accent);
|
|
40
|
+
box-shadow: 0 0 0 3px color-mix(in oklch, var(--tern-ring) 30%, transparent);
|
|
41
|
+
}
|
|
42
|
+
.tern-input-group:has(:global([aria-invalid='true'])) {
|
|
43
|
+
border-color: var(--tern-danger);
|
|
44
|
+
}
|
|
45
|
+
.tern-input-group > :global(.tern-input) {
|
|
46
|
+
flex: 1;
|
|
47
|
+
min-width: 0;
|
|
48
|
+
height: 100%;
|
|
49
|
+
border: 0;
|
|
50
|
+
box-shadow: none;
|
|
51
|
+
background: transparent;
|
|
52
|
+
}
|
|
53
|
+
.tern-input-group > :global(.tern-input:focus-visible) {
|
|
54
|
+
box-shadow: none;
|
|
55
|
+
}
|
|
56
|
+
.addon {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 0.25rem;
|
|
60
|
+
height: 100%;
|
|
61
|
+
color: var(--tern-muted);
|
|
62
|
+
font-size: 0.875rem;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
.start {
|
|
66
|
+
padding-inline-start: 0.75rem;
|
|
67
|
+
}
|
|
68
|
+
.end {
|
|
69
|
+
padding-inline-end: 0.75rem;
|
|
70
|
+
}
|
|
71
|
+
.start + :global(.tern-input) {
|
|
72
|
+
padding-inline-start: 0.4rem;
|
|
73
|
+
}
|
|
74
|
+
.addon :global(.tern-btn) {
|
|
75
|
+
height: calc(var(--tern-control-height) - 0.6rem);
|
|
76
|
+
}
|
|
77
|
+
.end:has(:global(.tern-btn)) {
|
|
78
|
+
padding-inline-end: 0.25rem;
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Props = {
|
|
3
|
+
/** Before the input: text such as "https://", an icon, or a button. */
|
|
4
|
+
start?: Snippet;
|
|
5
|
+
/** After the input: text such as ".com", a unit, or a button. */
|
|
6
|
+
end?: Snippet;
|
|
7
|
+
class?: string;
|
|
8
|
+
/** The Input. */
|
|
9
|
+
children: Snippet;
|
|
10
|
+
};
|
|
11
|
+
declare const InputGroup: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type InputGroup = ReturnType<typeof InputGroup>;
|
|
13
|
+
export default InputGroup;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getField } from './field.js';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
/** The code; bind it. */
|
|
6
|
+
value?: string;
|
|
7
|
+
/** How many characters. */
|
|
8
|
+
length?: number;
|
|
9
|
+
/** numeric for digits only, or alphanumeric. */
|
|
10
|
+
mode?: 'numeric' | 'alphanumeric';
|
|
11
|
+
/** Labels it when it isn't in a Field. */
|
|
12
|
+
label?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
class?: string;
|
|
16
|
+
/** Runs once every character is entered, such as to submit. */
|
|
17
|
+
oncomplete?: (code: string) => void;
|
|
18
|
+
};
|
|
19
|
+
const field = getField();
|
|
20
|
+
let { value = $bindable(field?.value ?? ''), length = 6, mode = 'numeric', label, name: nameProp, disabled = false, class: klass = '', oncomplete }: Props = $props();
|
|
21
|
+
const name = $derived(nameProp ?? field?.name);
|
|
22
|
+
|
|
23
|
+
let input = $state<HTMLInputElement>();
|
|
24
|
+
let focused = $state(false);
|
|
25
|
+
let caret = $state(0);
|
|
26
|
+
const pattern = $derived(mode === 'numeric' ? /[^0-9]/g : /[^0-9a-z]/gi);
|
|
27
|
+
|
|
28
|
+
function oninput() {
|
|
29
|
+
const clean = input!.value.replace(pattern, '').slice(0, length);
|
|
30
|
+
input!.value = clean;
|
|
31
|
+
value = clean;
|
|
32
|
+
caret = clean.length;
|
|
33
|
+
if (clean.length === length) oncomplete?.(clean);
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<!-- One real input, drawn as boxes: pasting, autofill from SMS and screen readers all just work. -->
|
|
38
|
+
<div class="tern-otp {klass}" class:focused class:disabled={disabled || field?.disabled} style:--length={length}>
|
|
39
|
+
<input
|
|
40
|
+
bind:this={input}
|
|
41
|
+
{value}
|
|
42
|
+
{name}
|
|
43
|
+
id={field?.id}
|
|
44
|
+
aria-label={field ? undefined : label}
|
|
45
|
+
aria-describedby={field?.describedBy}
|
|
46
|
+
aria-invalid={field?.invalid || undefined}
|
|
47
|
+
required={field?.required}
|
|
48
|
+
disabled={disabled || field?.disabled}
|
|
49
|
+
inputmode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
50
|
+
autocomplete="one-time-code"
|
|
51
|
+
autocapitalize="characters"
|
|
52
|
+
spellcheck="false"
|
|
53
|
+
pattern={mode === 'numeric' ? `[0-9]{${length}}` : `[0-9A-Za-z]{${length}}`}
|
|
54
|
+
{oninput}
|
|
55
|
+
onfocus={() => {
|
|
56
|
+
focused = true;
|
|
57
|
+
caret = value.length;
|
|
58
|
+
}}
|
|
59
|
+
onblur={() => (focused = false)}
|
|
60
|
+
onselect={() => (caret = Math.min(input?.selectionStart ?? 0, length - 1))}
|
|
61
|
+
/>
|
|
62
|
+
<div class="boxes" aria-hidden="true">
|
|
63
|
+
{#each { length }, i (i)}
|
|
64
|
+
<span class="box" class:active={focused && i === Math.min(caret, length - 1)} class:filled={i < value.length}>
|
|
65
|
+
{value[i] ?? ''}
|
|
66
|
+
{#if focused && i === value.length}<span class="caret"></span>{/if}
|
|
67
|
+
</span>
|
|
68
|
+
{/each}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<style>
|
|
73
|
+
.tern-otp {
|
|
74
|
+
position: relative;
|
|
75
|
+
width: fit-content;
|
|
76
|
+
}
|
|
77
|
+
input {
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset: 0;
|
|
80
|
+
width: 100%;
|
|
81
|
+
height: 100%;
|
|
82
|
+
opacity: 0;
|
|
83
|
+
font-size: 16px; /* no zoom on iOS */
|
|
84
|
+
letter-spacing: 2.6rem;
|
|
85
|
+
caret-color: transparent;
|
|
86
|
+
border: 0;
|
|
87
|
+
padding: 0;
|
|
88
|
+
cursor: text;
|
|
89
|
+
z-index: 1;
|
|
90
|
+
}
|
|
91
|
+
.boxes {
|
|
92
|
+
display: flex;
|
|
93
|
+
gap: 0.5rem;
|
|
94
|
+
}
|
|
95
|
+
.box {
|
|
96
|
+
position: relative;
|
|
97
|
+
display: grid;
|
|
98
|
+
place-items: center;
|
|
99
|
+
width: 2.5rem;
|
|
100
|
+
height: 2.875rem;
|
|
101
|
+
font-family: var(--tern-font-mono);
|
|
102
|
+
font-size: 1.25rem;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
color: var(--tern-fg);
|
|
106
|
+
background: var(--tern-surface);
|
|
107
|
+
border: 1px solid var(--tern-border-strong);
|
|
108
|
+
border-radius: var(--tern-radius-sm);
|
|
109
|
+
transition:
|
|
110
|
+
border-color var(--tern-duration),
|
|
111
|
+
box-shadow var(--tern-duration);
|
|
112
|
+
}
|
|
113
|
+
.box.active {
|
|
114
|
+
border-color: var(--tern-accent);
|
|
115
|
+
box-shadow: 0 0 0 3px color-mix(in oklch, var(--tern-ring) 30%, transparent);
|
|
116
|
+
}
|
|
117
|
+
.caret {
|
|
118
|
+
position: absolute;
|
|
119
|
+
width: 1px;
|
|
120
|
+
height: 1.25rem;
|
|
121
|
+
background: var(--tern-fg);
|
|
122
|
+
animation: blink 1s steps(1) infinite;
|
|
123
|
+
}
|
|
124
|
+
.tern-otp:has(input[aria-invalid='true']) .box {
|
|
125
|
+
border-color: var(--tern-danger);
|
|
126
|
+
}
|
|
127
|
+
.disabled {
|
|
128
|
+
opacity: 0.6;
|
|
129
|
+
}
|
|
130
|
+
@keyframes blink {
|
|
131
|
+
50% {
|
|
132
|
+
opacity: 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
@media (prefers-reduced-motion: reduce) {
|
|
136
|
+
.caret {
|
|
137
|
+
animation: none;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
/** The code; bind it. */
|
|
3
|
+
value?: string;
|
|
4
|
+
/** How many characters. */
|
|
5
|
+
length?: number;
|
|
6
|
+
/** numeric for digits only, or alphanumeric. */
|
|
7
|
+
mode?: 'numeric' | 'alphanumeric';
|
|
8
|
+
/** Labels it when it isn't in a Field. */
|
|
9
|
+
label?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
class?: string;
|
|
13
|
+
/** Runs once every character is entered, such as to submit. */
|
|
14
|
+
oncomplete?: (code: string) => void;
|
|
15
|
+
};
|
|
16
|
+
declare const InputOTP: import("svelte").Component<Props, {}, "value">;
|
|
17
|
+
type InputOTP = ReturnType<typeof InputOTP>;
|
|
18
|
+
export default InputOTP;
|