@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.
Files changed (131) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +2 -1
  3. package/dist/runtime/deferred.d.ts +5 -1
  4. package/dist/runtime/deferred.js +27 -11
  5. package/dist/runtime/validate.d.ts +31 -0
  6. package/dist/runtime/validate.js +168 -0
  7. package/dist/server/intl.js +55 -12
  8. package/dist/ui/AlertDialog.svelte +79 -0
  9. package/dist/ui/AlertDialog.svelte.d.ts +24 -0
  10. package/dist/ui/AreaChart.svelte +9 -0
  11. package/dist/ui/AreaChart.svelte.d.ts +26 -0
  12. package/dist/ui/AspectRatio.svelte +33 -0
  13. package/dist/ui/AspectRatio.svelte.d.ts +10 -0
  14. package/dist/ui/BarChart.svelte +170 -0
  15. package/dist/ui/BarChart.svelte.d.ts +40 -0
  16. package/dist/ui/Breadcrumb.svelte +92 -0
  17. package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
  18. package/dist/ui/ButtonGroup.svelte +61 -0
  19. package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
  20. package/dist/ui/Calendar.svelte +265 -0
  21. package/dist/ui/Calendar.svelte.d.ts +28 -0
  22. package/dist/ui/Carousel.svelte +169 -0
  23. package/dist/ui/Carousel.svelte.d.ts +36 -0
  24. package/dist/ui/ChartFrame.svelte +174 -0
  25. package/dist/ui/ChartFrame.svelte.d.ts +24 -0
  26. package/dist/ui/Checkbox.svelte +13 -1
  27. package/dist/ui/Collapsible.svelte +59 -0
  28. package/dist/ui/Collapsible.svelte.d.ts +12 -0
  29. package/dist/ui/Combobox.svelte +350 -0
  30. package/dist/ui/Combobox.svelte.d.ts +24 -0
  31. package/dist/ui/Command.svelte +268 -0
  32. package/dist/ui/Command.svelte.d.ts +28 -0
  33. package/dist/ui/ConsentScreen.svelte +125 -0
  34. package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
  35. package/dist/ui/ContextMenu.svelte +78 -0
  36. package/dist/ui/ContextMenu.svelte.d.ts +13 -0
  37. package/dist/ui/DataTable.svelte +277 -0
  38. package/dist/ui/DataTable.svelte.d.ts +53 -0
  39. package/dist/ui/DatePicker.svelte +138 -0
  40. package/dist/ui/DatePicker.svelte.d.ts +25 -0
  41. package/dist/ui/Dialog.svelte +168 -27
  42. package/dist/ui/Dialog.svelte.d.ts +10 -0
  43. package/dist/ui/DonutChart.svelte +115 -0
  44. package/dist/ui/DonutChart.svelte.d.ts +40 -0
  45. package/dist/ui/Drawer.svelte +19 -0
  46. package/dist/ui/Drawer.svelte.d.ts +14 -0
  47. package/dist/ui/Empty.svelte +62 -0
  48. package/dist/ui/Empty.svelte.d.ts +15 -0
  49. package/dist/ui/Field.svelte +22 -2
  50. package/dist/ui/Field.svelte.d.ts +2 -0
  51. package/dist/ui/Form.svelte +159 -0
  52. package/dist/ui/Form.svelte.d.ts +21 -0
  53. package/dist/ui/HoverCard.svelte +106 -0
  54. package/dist/ui/HoverCard.svelte.d.ts +26 -0
  55. package/dist/ui/Input.svelte +2 -1
  56. package/dist/ui/InputGroup.svelte +80 -0
  57. package/dist/ui/InputGroup.svelte.d.ts +13 -0
  58. package/dist/ui/InputOTP.svelte +140 -0
  59. package/dist/ui/InputOTP.svelte.d.ts +18 -0
  60. package/dist/ui/Item.svelte +86 -0
  61. package/dist/ui/Item.svelte.d.ts +17 -0
  62. package/dist/ui/Label.svelte +29 -0
  63. package/dist/ui/Label.svelte.d.ts +10 -0
  64. package/dist/ui/LineChart.svelte +171 -0
  65. package/dist/ui/LineChart.svelte.d.ts +42 -0
  66. package/dist/ui/Menu.svelte +22 -151
  67. package/dist/ui/Menu.svelte.d.ts +3 -10
  68. package/dist/ui/MenuList.svelte +317 -0
  69. package/dist/ui/MenuList.svelte.d.ts +29 -0
  70. package/dist/ui/Menubar.svelte +133 -0
  71. package/dist/ui/Menubar.svelte.d.ts +14 -0
  72. package/dist/ui/NavigationMenu.svelte +208 -0
  73. package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
  74. package/dist/ui/Pagination.svelte +114 -0
  75. package/dist/ui/Pagination.svelte.d.ts +16 -0
  76. package/dist/ui/PasswordInput.svelte +134 -0
  77. package/dist/ui/PasswordInput.svelte.d.ts +11 -0
  78. package/dist/ui/Popover.svelte +127 -0
  79. package/dist/ui/Popover.svelte.d.ts +29 -0
  80. package/dist/ui/Resizable.svelte +122 -0
  81. package/dist/ui/Resizable.svelte.d.ts +16 -0
  82. package/dist/ui/ScrollArea.svelte +43 -0
  83. package/dist/ui/ScrollArea.svelte.d.ts +13 -0
  84. package/dist/ui/Select.svelte +3 -2
  85. package/dist/ui/SelectMenu.svelte +301 -0
  86. package/dist/ui/SelectMenu.svelte.d.ts +18 -0
  87. package/dist/ui/Sheet.svelte +21 -0
  88. package/dist/ui/Sheet.svelte.d.ts +16 -0
  89. package/dist/ui/Sidebar.svelte +263 -0
  90. package/dist/ui/Sidebar.svelte.d.ts +35 -0
  91. package/dist/ui/SignIn.svelte +182 -0
  92. package/dist/ui/SignIn.svelte.d.ts +38 -0
  93. package/dist/ui/SignUp.svelte +106 -0
  94. package/dist/ui/SignUp.svelte.d.ts +23 -0
  95. package/dist/ui/Slider.svelte +196 -0
  96. package/dist/ui/Slider.svelte.d.ts +19 -0
  97. package/dist/ui/Sparkline.svelte +23 -0
  98. package/dist/ui/Sparkline.svelte.d.ts +11 -0
  99. package/dist/ui/Stat.svelte +91 -0
  100. package/dist/ui/Stat.svelte.d.ts +18 -0
  101. package/dist/ui/Table.svelte +70 -0
  102. package/dist/ui/Table.svelte.d.ts +36 -0
  103. package/dist/ui/Textarea.svelte +2 -1
  104. package/dist/ui/Toggle.svelte +82 -0
  105. package/dist/ui/Toggle.svelte.d.ts +17 -0
  106. package/dist/ui/ToggleGroup.svelte +143 -0
  107. package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
  108. package/dist/ui/Tooltip.svelte +27 -19
  109. package/dist/ui/Tooltip.svelte.d.ts +2 -1
  110. package/dist/ui/chart.d.ts +26 -0
  111. package/dist/ui/chart.js +89 -0
  112. package/dist/ui/dates.d.ts +13 -0
  113. package/dist/ui/dates.js +64 -0
  114. package/dist/ui/field.d.ts +8 -0
  115. package/dist/ui/floating.d.ts +44 -0
  116. package/dist/ui/floating.js +97 -0
  117. package/dist/ui/form.d.ts +12 -0
  118. package/dist/ui/form.js +8 -0
  119. package/dist/ui/index.d.ts +54 -0
  120. package/dist/ui/index.js +43 -0
  121. package/dist/ui/menu.d.ts +25 -0
  122. package/dist/ui/menu.js +1 -0
  123. package/dist/ui/options.d.ts +17 -0
  124. package/dist/ui/options.js +22 -0
  125. package/dist/ui/table.d.ts +26 -0
  126. package/dist/ui/table.js +1 -0
  127. package/dist/ui/theme.css +25 -0
  128. package/dist/vite/index.js +12 -3
  129. package/dist/vite/native.d.ts +3 -0
  130. package/dist/vite/native.js +122 -0
  131. package/package.json +3 -2
@@ -0,0 +1,182 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { FormField } from '../runtime/validate.js';
4
+ import Alert from './Alert.svelte';
5
+ import Button from './Button.svelte';
6
+ import Checkbox from './Checkbox.svelte';
7
+ import Field from './Field.svelte';
8
+ import Form from './Form.svelte';
9
+ import Input from './Input.svelte';
10
+ import PasswordInput from './PasswordInput.svelte';
11
+ import Separator from './Separator.svelte';
12
+
13
+ type Props = {
14
+ title?: string;
15
+ description?: string;
16
+ /** The heading level; 1 when this is the page's main content. */
17
+ level?: 1 | 2 | 3;
18
+ /** Providers from plugins/oauth, such as plugin.Providers(): each links to /auth/<name>/login. */
19
+ providers?: { name: string; label: string }[];
20
+ /** Where plugins/oauth lives. */
21
+ authPath?: string;
22
+ /** Where to go after signing in, a path on this site. */
23
+ next?: string;
24
+ /** Show the email and password form, which posts to action. */
25
+ password?: boolean;
26
+ action?: string;
27
+ /** The sign-in action's rules, from ./$forms. */
28
+ rules?: FormField[];
29
+ /** The page's form prop, for the server's errors. */
30
+ form?: unknown;
31
+ /** The ?error= code plugins/oauth sends back after a failed sign-in. */
32
+ error?: string | null;
33
+ /** A "Keep me signed in" checkbox, posted as remember. */
34
+ remember?: boolean;
35
+ /** Where to reset a forgotten password. */
36
+ forgotHref?: string;
37
+ /** Where to create an account. */
38
+ signUpHref?: string;
39
+ /** More content under the form, such as legal text. */
40
+ footer?: Snippet;
41
+ class?: string;
42
+ };
43
+ let {
44
+ title = 'Sign in',
45
+ description,
46
+ level = 1,
47
+ providers = [],
48
+ authPath = '/auth',
49
+ next,
50
+ password = true,
51
+ action,
52
+ rules,
53
+ form,
54
+ error,
55
+ remember = false,
56
+ forgotHref,
57
+ signUpHref,
58
+ footer,
59
+ class: klass = ''
60
+ }: Props = $props();
61
+
62
+ let submitting = $state(false);
63
+ 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.'
65
+ );
66
+ const login = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
67
+ </script>
68
+
69
+ <section class="tern-auth {klass}" aria-labelledby="tern-signin-title">
70
+ <header>
71
+ <svelte:element this={`h${level}`} id="tern-signin-title">{title}</svelte:element>
72
+ {#if description}<p>{description}</p>{/if}
73
+ </header>
74
+ {#if errorText}<Alert tone="danger" live>{errorText}</Alert>{/if}
75
+ {#if providers.length}
76
+ <div class="providers">
77
+ {#each providers as p (p.name)}
78
+ <Button variant="secondary" href={login(p.name)}>Continue with {p.label}</Button>
79
+ {/each}
80
+ </div>
81
+ {/if}
82
+ {#if providers.length && password}<div class="or"><Separator /><span>or</span><Separator /></div>{/if}
83
+ {#if password}
84
+ <Form {rules} {form} {action} bind:submitting reset={false}>
85
+ {#if next}<input type="hidden" name="next" value={next} />{/if}
86
+ <Field label="Email" name="email">
87
+ <Input type="email" autocomplete="username" spellcheck="false" />
88
+ </Field>
89
+ <Field label="Password" name="password">
90
+ <PasswordInput autocomplete="current-password" />
91
+ </Field>
92
+ <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}
95
+ </div>
96
+ <Button type="submit" loading={submitting}>Sign in</Button>
97
+ </Form>
98
+ {/if}
99
+ {#if signUpHref}<p class="switch">New here? <a href={signUpHref}>Create an account</a></p>{/if}
100
+ {#if footer}<div class="footer">{@render footer()}</div>{/if}
101
+ </section>
102
+
103
+ <style>
104
+ .tern-auth {
105
+ display: grid;
106
+ gap: 1.25rem;
107
+ width: 100%;
108
+ max-width: 24rem;
109
+ box-sizing: border-box;
110
+ padding: 2rem;
111
+ color: var(--tern-fg);
112
+ background: var(--tern-surface);
113
+ border: 1px solid var(--tern-border);
114
+ border-radius: calc(var(--tern-radius) + 4px);
115
+ box-shadow: var(--tern-shadow);
116
+ }
117
+ header {
118
+ display: grid;
119
+ gap: 0.35rem;
120
+ }
121
+ :is(h1, h2, h3) {
122
+ margin: 0;
123
+ font-size: 1.375rem;
124
+ font-weight: 650;
125
+ letter-spacing: -0.02em;
126
+ }
127
+ header p {
128
+ margin: 0;
129
+ color: var(--tern-muted);
130
+ font-size: 0.875rem;
131
+ }
132
+ .providers {
133
+ display: grid;
134
+ gap: 0.5rem;
135
+ }
136
+ .providers :global(.tern-btn) {
137
+ width: 100%;
138
+ }
139
+ .or {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 0.75rem;
143
+ color: var(--tern-muted);
144
+ font-size: 0.8125rem;
145
+ }
146
+ .or :global(.tern-separator) {
147
+ flex: 1;
148
+ }
149
+ .row {
150
+ display: flex;
151
+ flex-wrap: wrap;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ gap: 0.5rem;
155
+ font-size: 0.875rem;
156
+ }
157
+ .row:empty {
158
+ display: none;
159
+ }
160
+ a {
161
+ color: var(--tern-accent);
162
+ font-weight: 550;
163
+ }
164
+ a:focus-visible {
165
+ outline: 2px solid var(--tern-ring);
166
+ outline-offset: 2px;
167
+ border-radius: 2px;
168
+ }
169
+ .switch {
170
+ margin: 0;
171
+ text-align: center;
172
+ font-size: 0.875rem;
173
+ color: var(--tern-muted);
174
+ }
175
+ .footer {
176
+ font-size: 0.8125rem;
177
+ color: var(--tern-muted);
178
+ }
179
+ .tern-auth :global(.tern-form > .tern-btn) {
180
+ width: 100%;
181
+ }
182
+ </style>
@@ -0,0 +1,38 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { FormField } from '../runtime/validate.js';
3
+ type Props = {
4
+ title?: string;
5
+ description?: string;
6
+ /** The heading level; 1 when this is the page's main content. */
7
+ level?: 1 | 2 | 3;
8
+ /** Providers from plugins/oauth, such as plugin.Providers(): each links to /auth/<name>/login. */
9
+ providers?: {
10
+ name: string;
11
+ label: string;
12
+ }[];
13
+ /** Where plugins/oauth lives. */
14
+ authPath?: string;
15
+ /** Where to go after signing in, a path on this site. */
16
+ next?: string;
17
+ /** Show the email and password form, which posts to action. */
18
+ password?: boolean;
19
+ action?: string;
20
+ /** The sign-in action's rules, from ./$forms. */
21
+ rules?: FormField[];
22
+ /** The page's form prop, for the server's errors. */
23
+ form?: unknown;
24
+ /** The ?error= code plugins/oauth sends back after a failed sign-in. */
25
+ error?: string | null;
26
+ /** A "Keep me signed in" checkbox, posted as remember. */
27
+ remember?: boolean;
28
+ /** Where to reset a forgotten password. */
29
+ forgotHref?: string;
30
+ /** Where to create an account. */
31
+ signUpHref?: string;
32
+ /** More content under the form, such as legal text. */
33
+ footer?: Snippet;
34
+ class?: string;
35
+ };
36
+ declare const SignIn: import("svelte").Component<Props, {}, "">;
37
+ type SignIn = ReturnType<typeof SignIn>;
38
+ export default SignIn;
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { FormField } from '../runtime/validate.js';
4
+ import Button from './Button.svelte';
5
+ import Checkbox from './Checkbox.svelte';
6
+ import Field from './Field.svelte';
7
+ import Form from './Form.svelte';
8
+ import Input from './Input.svelte';
9
+ import PasswordInput from './PasswordInput.svelte';
10
+
11
+ type Props = {
12
+ title?: string;
13
+ description?: string;
14
+ level?: 1 | 2 | 3;
15
+ action?: string;
16
+ /** The sign-up action's rules, from ./$forms; they also set the password hint. */
17
+ rules?: FormField[];
18
+ form?: unknown;
19
+ /** Ask for a name, posted as name. */
20
+ name?: boolean;
21
+ /** Ask to type the password twice, posted as confirm. */
22
+ confirm?: boolean;
23
+ /** Links to the terms; then a checkbox, posted as terms, must be checked. */
24
+ termsHref?: string;
25
+ signInHref?: string;
26
+ footer?: Snippet;
27
+ class?: string;
28
+ };
29
+ let { title = 'Create an account', description, level = 1, action, rules, form, name = true, confirm = false, termsHref, signInHref, footer, class: klass = '' }: Props = $props();
30
+
31
+ let submitting = $state(false);
32
+ const minPassword = $derived(rules?.find((f) => f.name === 'password')?.rules?.find((r) => r.name === 'min')?.arg);
33
+ </script>
34
+
35
+ <section class="tern-auth {klass}" aria-labelledby="tern-signup-title">
36
+ <header>
37
+ <svelte:element this={`h${level}`} id="tern-signup-title">{title}</svelte:element>
38
+ {#if description}<p>{description}</p>{/if}
39
+ </header>
40
+ <Form {rules} {form} {action} bind:submitting reset={false}>
41
+ {#if name}
42
+ <Field label="Name" name="name"><Input autocomplete="name" /></Field>
43
+ {/if}
44
+ <Field label="Email" name="email"><Input type="email" autocomplete="email" spellcheck="false" /></Field>
45
+ <Field label="Password" name="password" description={minPassword ? `At least ${minPassword} characters.` : undefined}>
46
+ <PasswordInput autocomplete="new-password" />
47
+ </Field>
48
+ {#if confirm}
49
+ <Field label="Confirm password" name="confirm"><PasswordInput autocomplete="new-password" /></Field>
50
+ {/if}
51
+ {#if termsHref}
52
+ <Checkbox name="terms">I agree to the <a href={termsHref}>terms</a></Checkbox>
53
+ {/if}
54
+ <Button type="submit" loading={submitting}>Create account</Button>
55
+ </Form>
56
+ {#if signInHref}<p class="switch">Already have an account? <a href={signInHref}>Sign in</a></p>{/if}
57
+ {#if footer}<div class="footer">{@render footer()}</div>{/if}
58
+ </section>
59
+
60
+ <style>
61
+ .tern-auth {
62
+ display: grid;
63
+ gap: 1.25rem;
64
+ width: 100%;
65
+ max-width: 24rem;
66
+ box-sizing: border-box;
67
+ padding: 2rem;
68
+ color: var(--tern-fg);
69
+ background: var(--tern-surface);
70
+ border: 1px solid var(--tern-border);
71
+ border-radius: calc(var(--tern-radius) + 4px);
72
+ box-shadow: var(--tern-shadow);
73
+ }
74
+ header {
75
+ display: grid;
76
+ gap: 0.35rem;
77
+ }
78
+ :is(h1, h2, h3) {
79
+ margin: 0;
80
+ font-size: 1.375rem;
81
+ font-weight: 650;
82
+ letter-spacing: -0.02em;
83
+ }
84
+ header p {
85
+ margin: 0;
86
+ color: var(--tern-muted);
87
+ font-size: 0.875rem;
88
+ }
89
+ a {
90
+ color: var(--tern-accent);
91
+ font-weight: 550;
92
+ }
93
+ .switch {
94
+ margin: 0;
95
+ text-align: center;
96
+ font-size: 0.875rem;
97
+ color: var(--tern-muted);
98
+ }
99
+ .footer {
100
+ font-size: 0.8125rem;
101
+ color: var(--tern-muted);
102
+ }
103
+ .tern-auth :global(.tern-form > .tern-btn) {
104
+ width: 100%;
105
+ }
106
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { FormField } from '../runtime/validate.js';
3
+ type Props = {
4
+ title?: string;
5
+ description?: string;
6
+ level?: 1 | 2 | 3;
7
+ action?: string;
8
+ /** The sign-up action's rules, from ./$forms; they also set the password hint. */
9
+ rules?: FormField[];
10
+ form?: unknown;
11
+ /** Ask for a name, posted as name. */
12
+ name?: boolean;
13
+ /** Ask to type the password twice, posted as confirm. */
14
+ confirm?: boolean;
15
+ /** Links to the terms; then a checkbox, posted as terms, must be checked. */
16
+ termsHref?: string;
17
+ signInHref?: string;
18
+ footer?: Snippet;
19
+ class?: string;
20
+ };
21
+ declare const SignUp: import("svelte").Component<Props, {}, "">;
22
+ type SignUp = ReturnType<typeof SignUp>;
23
+ export default SignUp;
@@ -0,0 +1,196 @@
1
+ <script lang="ts">
2
+ import { getField } from './field.js';
3
+
4
+ type Props = {
5
+ /** A number for one thumb, or [low, high] for a range; bind it. */
6
+ value?: number | [number, number];
7
+ min?: number;
8
+ max?: number;
9
+ step?: number;
10
+ /** Labels it when it isn't in a Field. */
11
+ label?: string;
12
+ /** Formats values for screen readers and the tooltip, such as (v) => `${v}%`. */
13
+ format?: (v: number) => string;
14
+ /** Posts the value (or both values) under this name. */
15
+ name?: string;
16
+ disabled?: boolean;
17
+ class?: string;
18
+ onchange?: (value: number | [number, number]) => void;
19
+ };
20
+ const field = getField();
21
+ let { value = $bindable(0), min = 0, max = 100, step = 1, label, format = String, name: nameProp, disabled = false, class: klass = '', onchange }: Props = $props();
22
+ const name = $derived(nameProp ?? field?.name);
23
+
24
+ const uid = $props.id();
25
+ field?.custom();
26
+ let track = $state<HTMLDivElement>();
27
+ const thumbs: HTMLElement[] = [];
28
+ let dragging = $state<number | null>(null);
29
+
30
+ const values = $derived<number[]>(Array.isArray(value) ? value : [value]);
31
+ const range = $derived(Array.isArray(value));
32
+ const off = $derived(disabled || !!field?.disabled);
33
+ const pct = (v: number) => ((v - min) / (max - min || 1)) * 100;
34
+
35
+ function snap(v: number) {
36
+ const s = Math.round((v - min) / step) * step + min;
37
+ const decimals = (String(step).split('.')[1] ?? '').length;
38
+ return Math.min(max, Math.max(min, Number(s.toFixed(decimals))));
39
+ }
40
+
41
+ function set(i: number, v: number) {
42
+ v = snap(v);
43
+ if (range) {
44
+ const [lo, hi] = value as [number, number];
45
+ const next: [number, number] = i === 0 ? [Math.min(v, hi), hi] : [lo, Math.max(v, lo)];
46
+ if (next[0] === lo && next[1] === hi) return;
47
+ value = next;
48
+ } else {
49
+ if (v === value) return;
50
+ value = v;
51
+ }
52
+ onchange?.(value);
53
+ }
54
+
55
+ function fromPointer(e: PointerEvent) {
56
+ const r = track!.getBoundingClientRect();
57
+ return min + ((e.clientX - r.left) / r.width) * (max - min);
58
+ }
59
+
60
+ function down(e: PointerEvent) {
61
+ if (off || !track) return;
62
+ e.preventDefault();
63
+ const v = fromPointer(e);
64
+ // The nearest thumb moves; on a tie, the one that can go that way.
65
+ const i = range ? (Math.abs(v - values[0]!) < Math.abs(v - values[1]!) || (v < values[0]! && values[0] === values[1]) ? 0 : 1) : 0;
66
+ dragging = i;
67
+ set(i, v);
68
+ thumbs[i]?.focus();
69
+ track.setPointerCapture(e.pointerId);
70
+ }
71
+
72
+ function onkeydown(e: KeyboardEvent, i: number) {
73
+ const big = Math.max(step, (max - min) / 10);
74
+ const v = values[i]!;
75
+ const moves: Record<string, number> = {
76
+ ArrowRight: v + step,
77
+ ArrowUp: v + step,
78
+ ArrowLeft: v - step,
79
+ ArrowDown: v - step,
80
+ PageUp: v + big,
81
+ PageDown: v - big,
82
+ Home: min,
83
+ End: max
84
+ };
85
+ if (!(e.key in moves)) return;
86
+ e.preventDefault();
87
+ set(i, moves[e.key]!);
88
+ }
89
+ </script>
90
+
91
+ <div class="tern-slider {klass}" class:disabled={off}>
92
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
93
+ <div
94
+ bind:this={track}
95
+ class="track"
96
+ onpointerdown={down}
97
+ onpointermove={(e) => dragging !== null && set(dragging, fromPointer(e))}
98
+ onpointerup={() => (dragging = null)}
99
+ onpointercancel={() => (dragging = null)}
100
+ >
101
+ <div class="fill" style:left="{range ? pct(values[0]!) : 0}%" style:right="{100 - pct(values[values.length - 1]!)}%"></div>
102
+ {#each values as v, i (i)}
103
+ <span
104
+ bind:this={thumbs[i]}
105
+ class="thumb"
106
+ class:active={dragging === i}
107
+ id={i === 0 ? field?.id : undefined}
108
+ role="slider"
109
+ tabindex={off ? -1 : 0}
110
+ style:left="{pct(v)}%"
111
+ aria-valuemin={range && i === 1 ? values[0] : min}
112
+ aria-valuemax={range && i === 0 ? values[1] : max}
113
+ aria-valuenow={v}
114
+ aria-valuetext={format(v)}
115
+ aria-disabled={off || undefined}
116
+ aria-label={field || !label ? undefined : range ? `${label}, ${i === 0 ? 'minimum' : 'maximum'}` : label}
117
+ aria-labelledby={field ? (range ? `${field.labelId} tern-slider-${uid}-${i}` : field.labelId) : undefined}
118
+ aria-describedby={field?.describedBy}
119
+ onkeydown={(e) => onkeydown(e, i)}
120
+ >
121
+ <span class="value" aria-hidden="true">{format(v)}</span>
122
+ </span>
123
+ {#if range && field}<span class="tern-sr-only" id="tern-slider-{uid}-{i}">{i === 0 ? 'minimum' : 'maximum'}</span>{/if}
124
+ {/each}
125
+ </div>
126
+ {#if name}
127
+ {#each values as v, i (i)}<input type="hidden" {name} value={v} />{/each}
128
+ {/if}
129
+ </div>
130
+
131
+ <style>
132
+ .tern-slider {
133
+ padding: 0.6rem 0.5rem;
134
+ touch-action: none;
135
+ }
136
+ .track {
137
+ position: relative;
138
+ height: 0.375rem;
139
+ border-radius: 1rem;
140
+ background: var(--tern-surface-2);
141
+ box-shadow: inset 0 0 0 1px var(--tern-border);
142
+ cursor: pointer;
143
+ }
144
+ .fill {
145
+ position: absolute;
146
+ top: 0;
147
+ bottom: 0;
148
+ border-radius: inherit;
149
+ background: var(--tern-accent);
150
+ }
151
+ .thumb {
152
+ position: absolute;
153
+ top: 50%;
154
+ width: 1.125rem;
155
+ height: 1.125rem;
156
+ translate: -50% -50%;
157
+ border-radius: 50%;
158
+ background: var(--tern-surface);
159
+ border: 2px solid var(--tern-accent);
160
+ box-shadow: var(--tern-shadow);
161
+ cursor: grab;
162
+ outline: none;
163
+ transition: box-shadow var(--tern-duration);
164
+ }
165
+ .thumb:focus-visible,
166
+ .thumb.active {
167
+ box-shadow: 0 0 0 4px color-mix(in oklch, var(--tern-ring) 35%, transparent);
168
+ }
169
+ .value {
170
+ position: absolute;
171
+ bottom: calc(100% + 0.4rem);
172
+ left: 50%;
173
+ translate: -50% 0;
174
+ padding: 0.15rem 0.4rem;
175
+ border-radius: var(--tern-radius-sm);
176
+ background: var(--tern-fg);
177
+ color: var(--tern-bg);
178
+ font-size: 0.75rem;
179
+ white-space: nowrap;
180
+ opacity: 0;
181
+ pointer-events: none;
182
+ transition: opacity var(--tern-duration);
183
+ }
184
+ .thumb:focus-visible .value,
185
+ .thumb.active .value,
186
+ .thumb:hover .value {
187
+ opacity: 1;
188
+ }
189
+ .disabled {
190
+ opacity: 0.5;
191
+ }
192
+ .disabled .track,
193
+ .disabled .thumb {
194
+ cursor: not-allowed;
195
+ }
196
+ </style>
@@ -0,0 +1,19 @@
1
+ type Props = {
2
+ /** A number for one thumb, or [low, high] for a range; bind it. */
3
+ value?: number | [number, number];
4
+ min?: number;
5
+ max?: number;
6
+ step?: number;
7
+ /** Labels it when it isn't in a Field. */
8
+ label?: string;
9
+ /** Formats values for screen readers and the tooltip, such as (v) => `${v}%`. */
10
+ format?: (v: number) => string;
11
+ /** Posts the value (or both values) under this name. */
12
+ name?: string;
13
+ disabled?: boolean;
14
+ class?: string;
15
+ onchange?: (value: number | [number, number]) => void;
16
+ };
17
+ declare const Slider: import("svelte").Component<Props, {}, "value">;
18
+ type Slider = ReturnType<typeof Slider>;
19
+ export default Slider;
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ type Props = {
3
+ values: number[];
4
+ /** Describes the trend for screen readers, such as "Visits rose 12% this week". */
5
+ label: string;
6
+ width?: number;
7
+ height?: number;
8
+ class?: string;
9
+ };
10
+ let { values, label, width = 96, height = 28, class: klass = '' }: Props = $props();
11
+
12
+ const min = $derived(Math.min(...values));
13
+ const max = $derived(Math.max(...values));
14
+ const pts = $derived(values.map((v, i) => [2 + (i / Math.max(1, values.length - 1)) * (width - 6), 2 + (1 - (v - min) / (max - min || 1)) * (height - 4)] as const));
15
+ const d = $derived(pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(''));
16
+ const last = $derived(pts[pts.length - 1]);
17
+ </script>
18
+
19
+ <!-- A trend at a glance: the history in a muted line, the latest point in the accent. -->
20
+ <svg class="tern-sparkline {klass}" {width} {height} viewBox="0 0 {width} {height}" role="img" aria-label={label}>
21
+ <path {d} fill="none" stroke="var(--tern-border-strong)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
22
+ {#if last}<circle cx={last[0]} cy={last[1]} r="2.5" fill="var(--tern-accent)" />{/if}
23
+ </svg>
@@ -0,0 +1,11 @@
1
+ type Props = {
2
+ values: number[];
3
+ /** Describes the trend for screen readers, such as "Visits rose 12% this week". */
4
+ label: string;
5
+ width?: number;
6
+ height?: number;
7
+ class?: string;
8
+ };
9
+ declare const Sparkline: import("svelte").Component<Props, {}, "">;
10
+ type Sparkline = ReturnType<typeof Sparkline>;
11
+ export default Sparkline;
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import Sparkline from './Sparkline.svelte';
3
+ import { formatNumber } from './chart.js';
4
+
5
+ type Props = {
6
+ /** What it counts, in sentence case, such as "Unique visitors". */
7
+ label: string;
8
+ value: number;
9
+ /** The change, in percent, against the period named in period. */
10
+ delta?: number;
11
+ /** Such as "vs last week". */
12
+ period?: string;
13
+ /** Whether up is good (visits) or bad (errors), which picks the delta's color. */
14
+ upIsGood?: boolean;
15
+ /** Recent values, drawn as a sparkline. */
16
+ trend?: number[];
17
+ format?: (v: number) => string;
18
+ class?: string;
19
+ };
20
+ let { label, value, delta, period = 'vs the previous period', upIsGood = true, trend, format = (v) => formatNumber(v), class: klass = '' }: Props = $props();
21
+
22
+ const good = $derived(delta === undefined || delta === 0 ? null : delta > 0 === upIsGood);
23
+ const deltaText = $derived(delta === undefined ? '' : `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${Math.abs(Math.round(delta * 10) / 10)}%`);
24
+ </script>
25
+
26
+ <div class="tern-stat {klass}">
27
+ <div class="label">{label}</div>
28
+ <div class="value">{format(value)}</div>
29
+ <div class="foot">
30
+ {#if delta !== undefined}
31
+ <span class="delta" class:good={good === true} class:bad={good === false}>
32
+ <span aria-hidden="true">{delta > 0 ? '↑' : delta < 0 ? '↓' : '→'}</span>
33
+ <span class="tern-sr-only">{delta > 0 ? 'Up' : delta < 0 ? 'Down' : 'No change'}</span>
34
+ {deltaText}
35
+ </span>
36
+ <span class="period">{period}</span>
37
+ {/if}
38
+ {#if trend && trend.length > 1}
39
+ <Sparkline values={trend} label="{label}, trend over the last {trend.length} periods" class="spark" />
40
+ {/if}
41
+ </div>
42
+ </div>
43
+
44
+ <style>
45
+ .tern-stat {
46
+ display: grid;
47
+ gap: 0.25rem;
48
+ padding: 1rem 1.125rem;
49
+ background: var(--tern-surface);
50
+ border: 1px solid var(--tern-border);
51
+ border-radius: var(--tern-radius);
52
+ }
53
+ .label {
54
+ font-size: 0.8125rem;
55
+ font-weight: 550;
56
+ color: var(--tern-muted);
57
+ }
58
+ .value {
59
+ font-size: 1.75rem;
60
+ font-weight: 650;
61
+ letter-spacing: -0.02em;
62
+ font-variant-numeric: tabular-nums;
63
+ color: var(--tern-fg);
64
+ }
65
+ .foot {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 0.4rem;
69
+ min-height: 1.75rem;
70
+ font-size: 0.8125rem;
71
+ }
72
+ .delta {
73
+ display: inline-flex;
74
+ gap: 0.15rem;
75
+ font-weight: 600;
76
+ color: var(--tern-muted);
77
+ font-variant-numeric: tabular-nums;
78
+ }
79
+ .delta.good {
80
+ color: var(--tern-success);
81
+ }
82
+ .delta.bad {
83
+ color: var(--tern-danger);
84
+ }
85
+ .period {
86
+ color: var(--tern-muted);
87
+ }
88
+ .foot :global(.spark) {
89
+ margin-inline-start: auto;
90
+ }
91
+ </style>