@ternkit/tern 1.2.1 → 1.4.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 (170) hide show
  1. package/dist/goscript/index.svelte.d.ts +32 -2
  2. package/dist/goscript/index.svelte.js +257 -31
  3. package/dist/goscript/patch.d.ts +6 -0
  4. package/dist/goscript/patch.js +25 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +2 -1
  7. package/dist/runtime/validate.d.ts +31 -0
  8. package/dist/runtime/validate.js +168 -0
  9. package/dist/server/intl.js +55 -12
  10. package/dist/ui/Accordion.svelte +84 -0
  11. package/dist/ui/Accordion.svelte.d.ts +20 -0
  12. package/dist/ui/Alert.svelte +76 -0
  13. package/dist/ui/Alert.svelte.d.ts +12 -0
  14. package/dist/ui/AlertDialog.svelte +79 -0
  15. package/dist/ui/AlertDialog.svelte.d.ts +24 -0
  16. package/dist/ui/AreaChart.svelte +9 -0
  17. package/dist/ui/AreaChart.svelte.d.ts +26 -0
  18. package/dist/ui/AspectRatio.svelte +33 -0
  19. package/dist/ui/AspectRatio.svelte.d.ts +10 -0
  20. package/dist/ui/Avatar.svelte +48 -0
  21. package/dist/ui/Avatar.svelte.d.ts +10 -0
  22. package/dist/ui/Badge.svelte +54 -0
  23. package/dist/ui/Badge.svelte.d.ts +10 -0
  24. package/dist/ui/BarChart.svelte +170 -0
  25. package/dist/ui/BarChart.svelte.d.ts +40 -0
  26. package/dist/ui/Breadcrumb.svelte +92 -0
  27. package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
  28. package/dist/ui/Button.svelte +136 -0
  29. package/dist/ui/Button.svelte.d.ts +18 -0
  30. package/dist/ui/ButtonGroup.svelte +61 -0
  31. package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
  32. package/dist/ui/Calendar.svelte +265 -0
  33. package/dist/ui/Calendar.svelte.d.ts +28 -0
  34. package/dist/ui/Card.svelte +74 -0
  35. package/dist/ui/Card.svelte.d.ts +18 -0
  36. package/dist/ui/Carousel.svelte +169 -0
  37. package/dist/ui/Carousel.svelte.d.ts +36 -0
  38. package/dist/ui/ChartFrame.svelte +174 -0
  39. package/dist/ui/ChartFrame.svelte.d.ts +24 -0
  40. package/dist/ui/Checkbox.svelte +112 -0
  41. package/dist/ui/Checkbox.svelte.d.ts +14 -0
  42. package/dist/ui/Collapsible.svelte +59 -0
  43. package/dist/ui/Collapsible.svelte.d.ts +12 -0
  44. package/dist/ui/Combobox.svelte +350 -0
  45. package/dist/ui/Combobox.svelte.d.ts +24 -0
  46. package/dist/ui/Command.svelte +268 -0
  47. package/dist/ui/Command.svelte.d.ts +28 -0
  48. package/dist/ui/ConsentScreen.svelte +125 -0
  49. package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
  50. package/dist/ui/ContextMenu.svelte +78 -0
  51. package/dist/ui/ContextMenu.svelte.d.ts +13 -0
  52. package/dist/ui/DataTable.svelte +277 -0
  53. package/dist/ui/DataTable.svelte.d.ts +53 -0
  54. package/dist/ui/DatePicker.svelte +138 -0
  55. package/dist/ui/DatePicker.svelte.d.ts +25 -0
  56. package/dist/ui/Dialog.svelte +301 -0
  57. package/dist/ui/Dialog.svelte.d.ts +30 -0
  58. package/dist/ui/DonutChart.svelte +115 -0
  59. package/dist/ui/DonutChart.svelte.d.ts +40 -0
  60. package/dist/ui/Drawer.svelte +19 -0
  61. package/dist/ui/Drawer.svelte.d.ts +14 -0
  62. package/dist/ui/Empty.svelte +62 -0
  63. package/dist/ui/Empty.svelte.d.ts +15 -0
  64. package/dist/ui/Field.svelte +97 -0
  65. package/dist/ui/Field.svelte.d.ts +19 -0
  66. package/dist/ui/Form.svelte +159 -0
  67. package/dist/ui/Form.svelte.d.ts +21 -0
  68. package/dist/ui/HoverCard.svelte +106 -0
  69. package/dist/ui/HoverCard.svelte.d.ts +26 -0
  70. package/dist/ui/Input.svelte +62 -0
  71. package/dist/ui/Input.svelte.d.ts +7 -0
  72. package/dist/ui/InputGroup.svelte +80 -0
  73. package/dist/ui/InputGroup.svelte.d.ts +13 -0
  74. package/dist/ui/InputOTP.svelte +140 -0
  75. package/dist/ui/InputOTP.svelte.d.ts +18 -0
  76. package/dist/ui/Item.svelte +86 -0
  77. package/dist/ui/Item.svelte.d.ts +17 -0
  78. package/dist/ui/Kbd.svelte +24 -0
  79. package/dist/ui/Kbd.svelte.d.ts +7 -0
  80. package/dist/ui/Label.svelte +29 -0
  81. package/dist/ui/Label.svelte.d.ts +10 -0
  82. package/dist/ui/LineChart.svelte +171 -0
  83. package/dist/ui/LineChart.svelte.d.ts +42 -0
  84. package/dist/ui/Menu.svelte +103 -0
  85. package/dist/ui/Menu.svelte.d.ts +15 -0
  86. package/dist/ui/MenuList.svelte +317 -0
  87. package/dist/ui/MenuList.svelte.d.ts +29 -0
  88. package/dist/ui/Menubar.svelte +133 -0
  89. package/dist/ui/Menubar.svelte.d.ts +14 -0
  90. package/dist/ui/NavigationMenu.svelte +208 -0
  91. package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
  92. package/dist/ui/Pagination.svelte +114 -0
  93. package/dist/ui/Pagination.svelte.d.ts +16 -0
  94. package/dist/ui/PasswordInput.svelte +134 -0
  95. package/dist/ui/PasswordInput.svelte.d.ts +11 -0
  96. package/dist/ui/Popover.svelte +127 -0
  97. package/dist/ui/Popover.svelte.d.ts +29 -0
  98. package/dist/ui/Progress.svelte +73 -0
  99. package/dist/ui/Progress.svelte.d.ts +12 -0
  100. package/dist/ui/RadioGroup.svelte +105 -0
  101. package/dist/ui/RadioGroup.svelte.d.ts +21 -0
  102. package/dist/ui/Resizable.svelte +122 -0
  103. package/dist/ui/Resizable.svelte.d.ts +16 -0
  104. package/dist/ui/ScrollArea.svelte +43 -0
  105. package/dist/ui/ScrollArea.svelte.d.ts +13 -0
  106. package/dist/ui/Select.svelte +77 -0
  107. package/dist/ui/Select.svelte.d.ts +18 -0
  108. package/dist/ui/SelectMenu.svelte +301 -0
  109. package/dist/ui/SelectMenu.svelte.d.ts +18 -0
  110. package/dist/ui/Separator.svelte +19 -0
  111. package/dist/ui/Separator.svelte.d.ts +7 -0
  112. package/dist/ui/Sheet.svelte +21 -0
  113. package/dist/ui/Sheet.svelte.d.ts +16 -0
  114. package/dist/ui/Sidebar.svelte +263 -0
  115. package/dist/ui/Sidebar.svelte.d.ts +35 -0
  116. package/dist/ui/SignIn.svelte +182 -0
  117. package/dist/ui/SignIn.svelte.d.ts +38 -0
  118. package/dist/ui/SignUp.svelte +106 -0
  119. package/dist/ui/SignUp.svelte.d.ts +23 -0
  120. package/dist/ui/Skeleton.svelte +29 -0
  121. package/dist/ui/Skeleton.svelte.d.ts +9 -0
  122. package/dist/ui/Slider.svelte +196 -0
  123. package/dist/ui/Slider.svelte.d.ts +19 -0
  124. package/dist/ui/Sparkline.svelte +23 -0
  125. package/dist/ui/Sparkline.svelte.d.ts +11 -0
  126. package/dist/ui/Spinner.svelte +42 -0
  127. package/dist/ui/Spinner.svelte.d.ts +8 -0
  128. package/dist/ui/Stat.svelte +91 -0
  129. package/dist/ui/Stat.svelte.d.ts +18 -0
  130. package/dist/ui/Switch.svelte +90 -0
  131. package/dist/ui/Switch.svelte.d.ts +12 -0
  132. package/dist/ui/Table.svelte +70 -0
  133. package/dist/ui/Table.svelte.d.ts +36 -0
  134. package/dist/ui/Tabs.svelte +112 -0
  135. package/dist/ui/Tabs.svelte.d.ts +19 -0
  136. package/dist/ui/Textarea.svelte +52 -0
  137. package/dist/ui/Textarea.svelte.d.ts +7 -0
  138. package/dist/ui/Toaster.svelte +146 -0
  139. package/dist/ui/Toaster.svelte.d.ts +6 -0
  140. package/dist/ui/Toggle.svelte +82 -0
  141. package/dist/ui/Toggle.svelte.d.ts +17 -0
  142. package/dist/ui/ToggleGroup.svelte +143 -0
  143. package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
  144. package/dist/ui/Tooltip.svelte +85 -0
  145. package/dist/ui/Tooltip.svelte.d.ts +16 -0
  146. package/dist/ui/chart.d.ts +26 -0
  147. package/dist/ui/chart.js +89 -0
  148. package/dist/ui/dates.d.ts +13 -0
  149. package/dist/ui/dates.js +64 -0
  150. package/dist/ui/field.d.ts +18 -0
  151. package/dist/ui/field.js +8 -0
  152. package/dist/ui/floating.d.ts +44 -0
  153. package/dist/ui/floating.js +97 -0
  154. package/dist/ui/form.d.ts +12 -0
  155. package/dist/ui/form.js +8 -0
  156. package/dist/ui/index.d.ts +78 -0
  157. package/dist/ui/index.js +70 -0
  158. package/dist/ui/menu.d.ts +25 -0
  159. package/dist/ui/menu.js +1 -0
  160. package/dist/ui/options.d.ts +17 -0
  161. package/dist/ui/options.js +22 -0
  162. package/dist/ui/table.d.ts +26 -0
  163. package/dist/ui/table.js +1 -0
  164. package/dist/ui/theme.css +176 -0
  165. package/dist/ui/toast.svelte.d.ts +19 -0
  166. package/dist/ui/toast.svelte.js +18 -0
  167. package/dist/vite/goscript.d.ts +14 -0
  168. package/dist/vite/goscript.js +45 -16
  169. package/dist/vite/index.js +15 -4
  170. package/package.json +7 -1
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ type Tab = { id: string; label: string; disabled?: boolean };
5
+ type Props = {
6
+ tabs: Tab[];
7
+ /** The selected tab's id; bind it. Defaults to the first tab. */
8
+ value?: string;
9
+ /** Names the tab list for screen readers. */
10
+ label?: string;
11
+ class?: string;
12
+ /** Renders the selected tab's panel. */
13
+ panel: Snippet<[string]>;
14
+ };
15
+ let { tabs, value = $bindable(), label, class: klass = '', panel }: Props = $props();
16
+ const uid = $props.id();
17
+ const selected = $derived(value ?? tabs.find((t) => !t.disabled)?.id);
18
+ const buttons: HTMLButtonElement[] = [];
19
+
20
+ // The ARIA tabs pattern: arrow keys move and select, Home and End jump.
21
+ function onkeydown(e: KeyboardEvent, i: number) {
22
+ const enabled = tabs.map((t, j) => (t.disabled ? -1 : j)).filter((j) => j >= 0);
23
+ const at = enabled.indexOf(i);
24
+ let next: number | undefined;
25
+ if (e.key === 'ArrowRight') next = enabled[(at + 1) % enabled.length];
26
+ else if (e.key === 'ArrowLeft') next = enabled[(at - 1 + enabled.length) % enabled.length];
27
+ else if (e.key === 'Home') next = enabled[0];
28
+ else if (e.key === 'End') next = enabled[enabled.length - 1];
29
+ if (next === undefined) return;
30
+ e.preventDefault();
31
+ value = tabs[next]!.id;
32
+ buttons[next]?.focus();
33
+ }
34
+ </script>
35
+
36
+ <div class="tern-tabs {klass}">
37
+ <div class="list" role="tablist" aria-label={label}>
38
+ {#each tabs as t, i (t.id)}
39
+ <button
40
+ bind:this={buttons[i]}
41
+ type="button"
42
+ role="tab"
43
+ id="tern-tab-{uid}-{t.id}"
44
+ aria-selected={selected === t.id}
45
+ aria-controls="tern-tabpanel-{uid}-{t.id}"
46
+ tabindex={selected === t.id ? 0 : -1}
47
+ disabled={t.disabled}
48
+ onclick={() => (value = t.id)}
49
+ onkeydown={(e) => onkeydown(e, i)}>{t.label}</button
50
+ >
51
+ {/each}
52
+ </div>
53
+ {#if selected}
54
+ <div class="panel" role="tabpanel" id="tern-tabpanel-{uid}-{selected}" aria-labelledby="tern-tab-{uid}-{selected}" tabindex="0">
55
+ {@render panel(selected)}
56
+ </div>
57
+ {/if}
58
+ </div>
59
+
60
+ <style>
61
+ .list {
62
+ display: flex;
63
+ gap: 0.25rem;
64
+ border-bottom: 1px solid var(--tern-border);
65
+ }
66
+ [role='tab'] {
67
+ position: relative;
68
+ padding: 0.55rem 0.8rem;
69
+ margin-bottom: -1px;
70
+ border: 0;
71
+ background: transparent;
72
+ font: inherit;
73
+ font-size: 0.875rem;
74
+ font-weight: 550;
75
+ color: var(--tern-muted);
76
+ cursor: pointer;
77
+ border-radius: var(--tern-radius-sm) var(--tern-radius-sm) 0 0;
78
+ transition: color var(--tern-duration);
79
+ }
80
+ [role='tab']:hover:not(:disabled) {
81
+ color: var(--tern-fg);
82
+ }
83
+ [role='tab'][aria-selected='true'] {
84
+ color: var(--tern-fg);
85
+ }
86
+ [role='tab'][aria-selected='true']::after {
87
+ content: '';
88
+ position: absolute;
89
+ left: 0.5rem;
90
+ right: 0.5rem;
91
+ bottom: 0;
92
+ height: 2px;
93
+ border-radius: 2px;
94
+ background: var(--tern-accent);
95
+ }
96
+ [role='tab']:focus-visible {
97
+ outline: 2px solid var(--tern-ring);
98
+ outline-offset: -2px;
99
+ }
100
+ [role='tab']:disabled {
101
+ opacity: 0.45;
102
+ cursor: not-allowed;
103
+ }
104
+ .panel {
105
+ padding-top: 1rem;
106
+ }
107
+ .panel:focus-visible {
108
+ outline: 2px solid var(--tern-ring);
109
+ outline-offset: 4px;
110
+ border-radius: 2px;
111
+ }
112
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Tab = {
3
+ id: string;
4
+ label: string;
5
+ disabled?: boolean;
6
+ };
7
+ type Props = {
8
+ tabs: Tab[];
9
+ /** The selected tab's id; bind it. Defaults to the first tab. */
10
+ value?: string;
11
+ /** Names the tab list for screen readers. */
12
+ label?: string;
13
+ class?: string;
14
+ /** Renders the selected tab's panel. */
15
+ panel: Snippet<[string]>;
16
+ };
17
+ declare const Tabs: import("svelte").Component<Props, {}, "value">;
18
+ type Tabs = ReturnType<typeof Tabs>;
19
+ export default Tabs;
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
3
+ import { getField } from './field.js';
4
+
5
+ type Props = HTMLTextareaAttributes & { value?: unknown };
6
+ const field = getField();
7
+ let { value = $bindable(field?.value), class: klass = '', rows = 4, ...rest }: Props = $props();
8
+ </script>
9
+
10
+ <textarea
11
+ class="tern-textarea {klass}"
12
+ id={field?.id}
13
+ aria-describedby={field?.describedBy}
14
+ aria-invalid={field?.invalid || undefined}
15
+ required={field?.required}
16
+ disabled={field?.disabled}
17
+ {rows}
18
+ name={field?.name}
19
+ {...rest}
20
+ bind:value
21
+ ></textarea>
22
+
23
+ <style>
24
+ .tern-textarea {
25
+ width: 100%;
26
+ box-sizing: border-box;
27
+ padding: 0.5rem 0.75rem;
28
+ font: inherit;
29
+ font-size: 0.875rem;
30
+ line-height: 1.5;
31
+ color: var(--tern-fg);
32
+ background: var(--tern-surface);
33
+ border: 1px solid var(--tern-border-strong);
34
+ border-radius: var(--tern-radius-sm);
35
+ resize: vertical;
36
+ transition:
37
+ border-color var(--tern-duration),
38
+ box-shadow var(--tern-duration);
39
+ }
40
+ .tern-textarea:focus-visible {
41
+ outline: none;
42
+ border-color: var(--tern-accent);
43
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--tern-ring) 30%, transparent);
44
+ }
45
+ .tern-textarea[aria-invalid='true'] {
46
+ border-color: var(--tern-danger);
47
+ }
48
+ .tern-textarea:disabled {
49
+ opacity: 0.6;
50
+ cursor: not-allowed;
51
+ }
52
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
2
+ type Props = HTMLTextareaAttributes & {
3
+ value?: unknown;
4
+ };
5
+ declare const Textarea: import("svelte").Component<Props, {}, "value">;
6
+ type Textarea = ReturnType<typeof Textarea>;
7
+ export default Textarea;
@@ -0,0 +1,146 @@
1
+ <script lang="ts">
2
+ import { dismiss, toasts, type Toast } from './toast.svelte.js';
3
+
4
+ type Props = { position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' };
5
+ let { position = 'bottom-right' }: Props = $props();
6
+
7
+ // Each toast's timer pauses while it is hovered or focused.
8
+ function timed(node: HTMLElement, t: Toast) {
9
+ if (!t.duration) return;
10
+ let left = t.duration;
11
+ let started = Date.now();
12
+ let timer = setTimeout(() => dismiss(t.id), left);
13
+ const pause = () => {
14
+ clearTimeout(timer);
15
+ left -= Date.now() - started;
16
+ };
17
+ const resume = () => {
18
+ started = Date.now();
19
+ clearTimeout(timer);
20
+ timer = setTimeout(() => dismiss(t.id), Math.max(left, 1000));
21
+ };
22
+ node.addEventListener('pointerenter', pause);
23
+ node.addEventListener('pointerleave', resume);
24
+ node.addEventListener('focusin', pause);
25
+ node.addEventListener('focusout', resume);
26
+ return { destroy: () => clearTimeout(timer) };
27
+ }
28
+ const polite = $derived(toasts.filter((t) => t.tone !== 'danger'));
29
+ const urgent = $derived(toasts.filter((t) => t.tone === 'danger'));
30
+ </script>
31
+
32
+ {#snippet list(items: Toast[])}
33
+ {#each items as t (t.id)}
34
+ <li class="toast {t.tone}" use:timed={t}>
35
+ <div class="text">
36
+ <p class="message">{t.message}</p>
37
+ {#if t.description}<p class="description">{t.description}</p>{/if}
38
+ </div>
39
+ <button type="button" aria-label="Dismiss" onclick={() => dismiss(t.id)}>
40
+ <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
+ </button>
42
+ </li>
43
+ {/each}
44
+ {/snippet}
45
+
46
+ <section class="tern-toaster {position}" aria-label="Notifications">
47
+ <!-- Errors are announced at once; the rest when the reader is idle. -->
48
+ <ol aria-live="assertive" aria-relevant="additions">{@render list(urgent)}</ol>
49
+ <ol aria-live="polite" aria-relevant="additions">{@render list(polite)}</ol>
50
+ </section>
51
+
52
+ <style>
53
+ .tern-toaster {
54
+ position: fixed;
55
+ z-index: 100;
56
+ display: grid;
57
+ gap: 0.5rem;
58
+ width: min(22rem, calc(100vw - 2rem));
59
+ pointer-events: none;
60
+ }
61
+ .bottom-right {
62
+ right: 1rem;
63
+ bottom: 1rem;
64
+ }
65
+ .bottom-left {
66
+ left: 1rem;
67
+ bottom: 1rem;
68
+ }
69
+ .top-right {
70
+ right: 1rem;
71
+ top: 1rem;
72
+ }
73
+ .top-left {
74
+ left: 1rem;
75
+ top: 1rem;
76
+ }
77
+ ol {
78
+ display: grid;
79
+ gap: 0.5rem;
80
+ margin: 0;
81
+ padding: 0;
82
+ list-style: none;
83
+ }
84
+ .toast {
85
+ display: flex;
86
+ align-items: flex-start;
87
+ gap: 0.75rem;
88
+ padding: 0.8rem 0.9rem;
89
+ background: var(--tern-surface);
90
+ color: var(--tern-fg);
91
+ border: 1px solid var(--tern-border);
92
+ border-left: 3px solid var(--tern-border-strong);
93
+ border-radius: var(--tern-radius);
94
+ box-shadow: var(--tern-shadow-lg);
95
+ pointer-events: auto;
96
+ animation: slide var(--tern-duration) ease-out;
97
+ }
98
+ .success {
99
+ border-left-color: var(--tern-success);
100
+ }
101
+ .danger {
102
+ border-left-color: var(--tern-danger);
103
+ }
104
+ .warning {
105
+ border-left-color: var(--tern-warning);
106
+ }
107
+ .text {
108
+ flex: 1;
109
+ min-width: 0;
110
+ }
111
+ p {
112
+ margin: 0;
113
+ }
114
+ .message {
115
+ font-size: 0.875rem;
116
+ font-weight: 550;
117
+ }
118
+ .description {
119
+ margin-top: 0.15rem;
120
+ font-size: 0.8125rem;
121
+ color: var(--tern-muted);
122
+ }
123
+ button {
124
+ display: grid;
125
+ place-items: center;
126
+ width: 1.5rem;
127
+ height: 1.5rem;
128
+ border: 0;
129
+ border-radius: var(--tern-radius-sm);
130
+ background: transparent;
131
+ color: var(--tern-muted);
132
+ cursor: pointer;
133
+ }
134
+ button:hover {
135
+ background: var(--tern-surface-2);
136
+ }
137
+ button:focus-visible {
138
+ outline: 2px solid var(--tern-ring);
139
+ }
140
+ @keyframes slide {
141
+ from {
142
+ opacity: 0;
143
+ transform: translateY(6px);
144
+ }
145
+ }
146
+ </style>
@@ -0,0 +1,6 @@
1
+ type Props = {
2
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
3
+ };
4
+ declare const Toaster: import("svelte").Component<Props, {}, "">;
5
+ type Toaster = ReturnType<typeof Toaster>;
6
+ export default Toaster;
@@ -0,0 +1,82 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLButtonAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLButtonAttributes, 'value'> & {
6
+ /** Whether it's on; bind it. */
7
+ pressed?: boolean;
8
+ variant?: 'secondary' | 'ghost';
9
+ size?: 'sm' | 'md';
10
+ /** Posts value (or "on") under this name while pressed. */
11
+ name?: string;
12
+ value?: string;
13
+ /** The label. For an icon alone, set aria-label. */
14
+ children: Snippet;
15
+ onchange?: (pressed: boolean) => void;
16
+ };
17
+ let { pressed = $bindable(false), variant = 'secondary', size = 'md', name, value = 'on', class: klass = '', children, onchange, ...rest }: Props = $props();
18
+ </script>
19
+
20
+ <button
21
+ type="button"
22
+ class="tern-toggle {variant} {size} {klass}"
23
+ aria-pressed={pressed}
24
+ {...rest}
25
+ onclick={() => {
26
+ pressed = !pressed;
27
+ onchange?.(pressed);
28
+ }}
29
+ >
30
+ {@render children()}
31
+ </button>
32
+ {#if name && pressed}<input type="hidden" {name} {value} />{/if}
33
+
34
+ <style>
35
+ .tern-toggle {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ gap: 0.4rem;
40
+ height: var(--tern-control-height);
41
+ min-width: var(--tern-control-height);
42
+ padding: 0 0.7rem;
43
+ font: inherit;
44
+ font-size: 0.875rem;
45
+ font-weight: 550;
46
+ color: var(--tern-muted);
47
+ border: 1px solid transparent;
48
+ border-radius: var(--tern-radius-sm);
49
+ background: transparent;
50
+ cursor: pointer;
51
+ transition:
52
+ background-color var(--tern-duration),
53
+ color var(--tern-duration);
54
+ }
55
+ .secondary {
56
+ border-color: var(--tern-border-strong);
57
+ background: var(--tern-surface);
58
+ }
59
+ .sm {
60
+ height: calc(var(--tern-control-height) - 0.5rem);
61
+ min-width: calc(var(--tern-control-height) - 0.5rem);
62
+ padding: 0 0.5rem;
63
+ font-size: 0.8125rem;
64
+ }
65
+ .tern-toggle:hover:not(:disabled) {
66
+ color: var(--tern-fg);
67
+ background: var(--tern-surface-2);
68
+ }
69
+ .tern-toggle[aria-pressed='true'] {
70
+ color: var(--tern-fg);
71
+ background: var(--tern-accent-soft);
72
+ border-color: color-mix(in oklch, var(--tern-accent) 45%, transparent);
73
+ }
74
+ .tern-toggle:focus-visible {
75
+ outline: 2px solid var(--tern-ring);
76
+ outline-offset: 2px;
77
+ }
78
+ .tern-toggle:disabled {
79
+ opacity: 0.5;
80
+ cursor: not-allowed;
81
+ }
82
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLButtonAttributes, 'value'> & {
4
+ /** Whether it's on; bind it. */
5
+ pressed?: boolean;
6
+ variant?: 'secondary' | 'ghost';
7
+ size?: 'sm' | 'md';
8
+ /** Posts value (or "on") under this name while pressed. */
9
+ name?: string;
10
+ value?: string;
11
+ /** The label. For an icon alone, set aria-label. */
12
+ children: Snippet;
13
+ onchange?: (pressed: boolean) => void;
14
+ };
15
+ declare const Toggle: import("svelte").Component<Props, {}, "pressed">;
16
+ type Toggle = ReturnType<typeof Toggle>;
17
+ export default Toggle;
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getField } from './field.js';
4
+
5
+ type Option = { value: string; label: string; disabled?: boolean; icon?: Snippet };
6
+ type Props = {
7
+ options: Option[];
8
+ /** single: one at a time, like radio buttons; multiple: any number, like checkboxes. */
9
+ type?: 'single' | 'multiple';
10
+ /** The chosen value (single) or values (multiple); bind it. */
11
+ value?: string | string[] | null;
12
+ /** Labels the group when it isn't in a Field. */
13
+ label?: string;
14
+ /** Show only the icons; the labels stay for screen readers. */
15
+ iconsOnly?: boolean;
16
+ /** Posts the chosen values under this name. */
17
+ name?: string;
18
+ size?: 'sm' | 'md';
19
+ disabled?: boolean;
20
+ class?: string;
21
+ onchange?: (value: string | string[] | null) => void;
22
+ };
23
+ const field = getField();
24
+ let { options, type = 'single', value = $bindable(type === 'multiple' ? [] : null), label, iconsOnly = false, name: nameProp, size = 'md', disabled = false, class: klass = '', onchange }: Props = $props();
25
+ const name = $derived(nameProp ?? field?.name);
26
+
27
+ field?.custom();
28
+ const buttons: HTMLButtonElement[] = [];
29
+ const chosen = (v: string) => (Array.isArray(value) ? value.includes(v) : value === v);
30
+ const enabled = $derived(options.map((o, i) => ({ o, i })).filter(({ o }) => !o.disabled && !disabled));
31
+ // Roving tabindex: the chosen item, else the first enabled one.
32
+ const tabStop = $derived(enabled.find(({ o }) => chosen(o.value))?.i ?? enabled[0]?.i ?? -1);
33
+
34
+ function pick(v: string) {
35
+ if (type === 'multiple') {
36
+ const list = Array.isArray(value) ? value : [];
37
+ value = list.includes(v) ? list.filter((x) => x !== v) : [...list, v];
38
+ } else value = value === v ? null : v;
39
+ onchange?.(value);
40
+ }
41
+
42
+ function onkeydown(e: KeyboardEvent, i: number) {
43
+ const pos = enabled.findIndex((x) => x.i === i);
44
+ let next: number | undefined;
45
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = enabled[(pos + 1) % enabled.length]?.i;
46
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = enabled[(pos - 1 + enabled.length) % enabled.length]?.i;
47
+ else if (e.key === 'Home') next = enabled[0]?.i;
48
+ else if (e.key === 'End') next = enabled[enabled.length - 1]?.i;
49
+ if (next === undefined) return;
50
+ e.preventDefault();
51
+ buttons[next]?.focus();
52
+ // Like radio buttons, moving selects in single mode.
53
+ if (type === 'single') {
54
+ value = options[next]!.value;
55
+ onchange?.(value);
56
+ }
57
+ }
58
+ </script>
59
+
60
+ <div
61
+ class="tern-toggle-group {size} {klass}"
62
+ role={type === 'single' ? 'radiogroup' : 'group'}
63
+ aria-label={field ? undefined : label}
64
+ aria-labelledby={field?.labelId}
65
+ aria-describedby={field?.describedBy}
66
+ >
67
+ {#each options as option, i (option.value)}
68
+ <button
69
+ bind:this={buttons[i]}
70
+ id={i === tabStop ? field?.id : undefined}
71
+ type="button"
72
+ role={type === 'single' ? 'radio' : undefined}
73
+ aria-checked={type === 'single' ? chosen(option.value) : undefined}
74
+ aria-pressed={type === 'multiple' ? chosen(option.value) : undefined}
75
+ aria-label={iconsOnly ? option.label : undefined}
76
+ title={iconsOnly ? option.label : undefined}
77
+ tabindex={i === tabStop ? 0 : -1}
78
+ disabled={option.disabled || disabled || field?.disabled}
79
+ onclick={() => pick(option.value)}
80
+ onkeydown={(e) => onkeydown(e, i)}
81
+ >
82
+ {#if option.icon}{@render option.icon()}{/if}
83
+ {#if !iconsOnly}{option.label}{/if}
84
+ </button>
85
+ {/each}
86
+ </div>
87
+ {#if name}
88
+ {#each options.filter((o) => chosen(o.value)) as o (o.value)}<input type="hidden" {name} value={o.value} />{/each}
89
+ {/if}
90
+
91
+ <style>
92
+ .tern-toggle-group {
93
+ display: inline-flex;
94
+ gap: 0.125rem;
95
+ padding: 0.2rem;
96
+ background: var(--tern-surface-2);
97
+ border: 1px solid var(--tern-border);
98
+ border-radius: var(--tern-radius);
99
+ width: fit-content;
100
+ }
101
+ button {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ gap: 0.4rem;
106
+ height: calc(var(--tern-control-height) - 0.4rem);
107
+ min-width: calc(var(--tern-control-height) - 0.4rem);
108
+ padding: 0 0.7rem;
109
+ font: inherit;
110
+ font-size: 0.875rem;
111
+ font-weight: 550;
112
+ color: var(--tern-muted);
113
+ background: transparent;
114
+ border: 0;
115
+ border-radius: var(--tern-radius-sm);
116
+ cursor: pointer;
117
+ transition:
118
+ background-color var(--tern-duration),
119
+ color var(--tern-duration);
120
+ }
121
+ .sm button {
122
+ height: calc(var(--tern-control-height) - 0.8rem);
123
+ padding: 0 0.5rem;
124
+ font-size: 0.8125rem;
125
+ }
126
+ button:hover:not(:disabled) {
127
+ color: var(--tern-fg);
128
+ }
129
+ button[aria-checked='true'],
130
+ button[aria-pressed='true'] {
131
+ color: var(--tern-fg);
132
+ background: var(--tern-surface);
133
+ box-shadow: var(--tern-shadow);
134
+ }
135
+ button:focus-visible {
136
+ outline: 2px solid var(--tern-ring);
137
+ outline-offset: 1px;
138
+ }
139
+ button:disabled {
140
+ opacity: 0.45;
141
+ cursor: not-allowed;
142
+ }
143
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Option = {
3
+ value: string;
4
+ label: string;
5
+ disabled?: boolean;
6
+ icon?: Snippet;
7
+ };
8
+ type Props = {
9
+ options: Option[];
10
+ /** single: one at a time, like radio buttons; multiple: any number, like checkboxes. */
11
+ type?: 'single' | 'multiple';
12
+ /** The chosen value (single) or values (multiple); bind it. */
13
+ value?: string | string[] | null;
14
+ /** Labels the group when it isn't in a Field. */
15
+ label?: string;
16
+ /** Show only the icons; the labels stay for screen readers. */
17
+ iconsOnly?: boolean;
18
+ /** Posts the chosen values under this name. */
19
+ name?: string;
20
+ size?: 'sm' | 'md';
21
+ disabled?: boolean;
22
+ class?: string;
23
+ onchange?: (value: string | string[] | null) => void;
24
+ };
25
+ declare const ToggleGroup: import("svelte").Component<Props, {}, "value">;
26
+ type ToggleGroup = ReturnType<typeof ToggleGroup>;
27
+ export default ToggleGroup;