@mystravil/hardpoint 0.0.0-stage → 0.1.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 (112) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +346 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +198 -0
  10. package/dist/components/Checkbox.svelte.d.ts +16 -0
  11. package/dist/components/CheckboxGroup.svelte +99 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
  13. package/dist/components/Combobox.svelte +824 -0
  14. package/dist/components/Combobox.svelte.d.ts +63 -0
  15. package/dist/components/Dialog.svelte +356 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/KeyValueList.svelte +114 -0
  24. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  25. package/dist/components/LoadRegion.svelte +219 -0
  26. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  27. package/dist/components/MediaFrame.svelte +110 -0
  28. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  29. package/dist/components/MediaImage.svelte +256 -0
  30. package/dist/components/MediaImage.svelte.d.ts +16 -0
  31. package/dist/components/Menu.svelte +258 -0
  32. package/dist/components/Menu.svelte.d.ts +36 -0
  33. package/dist/components/MenuCheckboxItem.svelte +119 -0
  34. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  35. package/dist/components/MenuGroup.svelte +50 -0
  36. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  37. package/dist/components/MenuItem.svelte +94 -0
  38. package/dist/components/MenuItem.svelte.d.ts +19 -0
  39. package/dist/components/MenuRadioGroup.svelte +62 -0
  40. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  41. package/dist/components/MenuRadioItem.svelte +110 -0
  42. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  43. package/dist/components/MenuSeparator.svelte +28 -0
  44. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  45. package/dist/components/NativeSelect.svelte +119 -0
  46. package/dist/components/NativeSelect.svelte.d.ts +46 -0
  47. package/dist/components/Pagination.svelte +132 -0
  48. package/dist/components/Pagination.svelte.d.ts +18 -0
  49. package/dist/components/Popover.svelte +222 -0
  50. package/dist/components/Popover.svelte.d.ts +35 -0
  51. package/dist/components/Progress.svelte +157 -0
  52. package/dist/components/Progress.svelte.d.ts +34 -0
  53. package/dist/components/Select.svelte +405 -0
  54. package/dist/components/Select.svelte.d.ts +42 -0
  55. package/dist/components/Skeleton.svelte +149 -0
  56. package/dist/components/Skeleton.svelte.d.ts +24 -0
  57. package/dist/components/Spinner.svelte +117 -0
  58. package/dist/components/Spinner.svelte.d.ts +13 -0
  59. package/dist/components/Stat.svelte +253 -0
  60. package/dist/components/Stat.svelte.d.ts +46 -0
  61. package/dist/components/Steps.svelte +189 -0
  62. package/dist/components/Steps.svelte.d.ts +31 -0
  63. package/dist/components/Switch.svelte +222 -0
  64. package/dist/components/Switch.svelte.d.ts +18 -0
  65. package/dist/components/Table.svelte +504 -0
  66. package/dist/components/Table.svelte.d.ts +92 -0
  67. package/dist/components/Tabs.svelte +246 -0
  68. package/dist/components/Tabs.svelte.d.ts +44 -0
  69. package/dist/components/Tag.svelte +40 -0
  70. package/dist/components/Tag.svelte.d.ts +15 -0
  71. package/dist/components/TextField.svelte +256 -0
  72. package/dist/components/TextField.svelte.d.ts +32 -0
  73. package/dist/components/Timeline.svelte +156 -0
  74. package/dist/components/Timeline.svelte.d.ts +30 -0
  75. package/dist/components/Toaster.svelte +411 -0
  76. package/dist/components/Toaster.svelte.d.ts +11 -0
  77. package/dist/components/Tooltip.svelte +200 -0
  78. package/dist/components/Tooltip.svelte.d.ts +38 -0
  79. package/dist/components/TooltipGroup.svelte +46 -0
  80. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  81. package/dist/components/internal/Scrim.svelte +52 -0
  82. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  83. package/dist/components/internal/Sheet.svelte +263 -0
  84. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  85. package/dist/components/internal/fade-in.d.ts +10 -0
  86. package/dist/components/internal/fade-in.js +20 -0
  87. package/dist/components/internal/resolve.d.ts +8 -0
  88. package/dist/components/internal/resolve.js +247 -0
  89. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  90. package/dist/components/internal/swipe-dismiss.js +220 -0
  91. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  92. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  93. package/dist/components/toast.svelte.d.ts +57 -0
  94. package/dist/components/toast.svelte.js +84 -0
  95. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  96. package/dist/fonts/OFL.txt +93 -0
  97. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  98. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  99. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  100. package/dist/fonts.css +71 -0
  101. package/dist/index.d.ts +39 -0
  102. package/dist/index.js +39 -0
  103. package/dist/motion/Motion.svelte +15 -0
  104. package/dist/motion/Motion.svelte.d.ts +9 -0
  105. package/dist/motion/context.d.ts +18 -0
  106. package/dist/motion/context.js +26 -0
  107. package/dist/motion/index.d.ts +3 -0
  108. package/dist/motion/index.js +3 -0
  109. package/dist/motion/transitions.d.ts +31 -0
  110. package/dist/motion/transitions.js +112 -0
  111. package/dist/tokens.css +279 -0
  112. package/package.json +76 -6
@@ -0,0 +1,246 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Tabs as BitsTabs } from 'bits-ui';
4
+
5
+ export type TabsItem = {
6
+ /** Identifies the tab; the bound `value` holds the selected one. */
7
+ value: string;
8
+ /** The caps label. A short fixed string the app defines, never user data. */
9
+ label: string;
10
+ /** Shown in `--muted`; it cannot be selected and arrow keys skip it. */
11
+ disabled?: boolean;
12
+ };
13
+
14
+ /** Tabs may carry extra fields (a count, an icon name) that the `panel` snippet reads. */
15
+ export type TabsProps<T extends TabsItem = TabsItem> = Omit<
16
+ BitsTabs.RootProps,
17
+ 'value' | 'orientation' | 'child' | 'children'
18
+ > & {
19
+ tabs: T[];
20
+ /** The selected tab's `value`. Bindable; defaults to the first tab that is not disabled. */
21
+ value?: string;
22
+ /** The tab list's accessible name, for example "Project sections". */
23
+ label: string;
24
+ /** Renders one tab's panel. Every panel stays mounted; the hidden ones carry `hidden`. */
25
+ panel: Snippet<[T]>;
26
+ };
27
+ </script>
28
+
29
+ <script lang="ts" generics="T extends TabsItem = TabsItem">
30
+ import { untrack } from 'svelte';
31
+ import { fadeIn } from './internal/fade-in.js';
32
+
33
+ let {
34
+ tabs,
35
+ value = $bindable(tabs.find((tab) => !tab.disabled)?.value ?? ''),
36
+ label,
37
+ panel,
38
+ class: className,
39
+ ref = $bindable(null),
40
+ onValueChange,
41
+ ...rest
42
+ }: TabsProps<T> = $props();
43
+
44
+ let list: HTMLElement | null = $state(null);
45
+
46
+ // Shows the selected tab whole inside the list, with part of the next one
47
+ // (the list's scroll-padding). It scrolls the list alone, never the page
48
+ // (scrollIntoView would), and jumps: selection snaps (motion rule 3).
49
+ function reveal(list: HTMLElement, selected: string) {
50
+ const tab = Array.from(list.children).find(
51
+ (el): el is HTMLElement => el instanceof HTMLElement && el.dataset.value === selected
52
+ );
53
+ if (!tab) return;
54
+ const style = getComputedStyle(list);
55
+ // The tab's edges in the list's scroll coordinates, from rects: offsetLeft
56
+ // and offsetWidth round to whole pixels. The target rounds outward, so
57
+ // the padding never comes up a fraction short.
58
+ const listLeft = list.getBoundingClientRect().left + list.clientLeft;
59
+ const rect = tab.getBoundingClientRect();
60
+ const left = rect.left - listLeft + list.scrollLeft;
61
+ const start = Math.floor(left - (parseFloat(style.scrollPaddingLeft) || 0));
62
+ const end = Math.ceil(left + rect.width + (parseFloat(style.scrollPaddingRight) || 0));
63
+ if (start < list.scrollLeft) {
64
+ list.scrollTo({ left: start, behavior: 'instant' });
65
+ } else if (end > list.scrollLeft + list.clientWidth) {
66
+ list.scrollTo({ left: end - list.clientWidth, behavior: 'instant' });
67
+ }
68
+ }
69
+
70
+ // On the first render and whenever the selection changes.
71
+ $effect(() => {
72
+ if (list) reveal(list, value);
73
+ });
74
+
75
+ // Again whenever the list or a tab changes size: the web font arrives, the
76
+ // screen turns, or a hidden parent (another tab panel) shows the list. The
77
+ // watcher restarts whenever the set of tabs changes, not only their number,
78
+ // so a tab an app swaps in for another is watched too (each tab element is
79
+ // keyed by its value).
80
+ $effect(() => {
81
+ const el = list;
82
+ const values = tabs.map((tab) => tab.value);
83
+ if (!el || values.length === 0) return;
84
+ const observer = new ResizeObserver(() => reveal(el, value));
85
+ observer.observe(el);
86
+ for (const tab of el.children) observer.observe(tab);
87
+ return () => observer.disconnect();
88
+ });
89
+
90
+ // ---- Short motion: the panel (BRIEF, --dur-panel) ----------------------
91
+
92
+ // A panel chosen by a pointer or a touch fades in from 0 over
93
+ // --dur-panel. A panel chosen by a key (arrows, Home, End, Enter, Space)
94
+ // or by the app through `value` cuts, so holding an arrow key through the
95
+ // tabs never starts a fade (the frequency check). The input: the tab list
96
+ // keeps the last one it saw, from pointerdown and keydown in the capture
97
+ // phase, before Bits UI acts on them. Bits UI calls onValueChange only for
98
+ // a selection the user makes, never for a `value` the app sets, so the
99
+ // change notes there whether a pointer made it, and the effect below
100
+ // fades the new panel once it shows. A new selection cancels a fade that
101
+ // still runs. 0ms is a cut, as before the token.
102
+ let input: 'pointer' | 'key' | null = null;
103
+ let fadeTo: string | null = null;
104
+ let fade: Animation | null = null;
105
+
106
+ function changed(next: string) {
107
+ fadeTo = input === 'pointer' ? next : null;
108
+ onValueChange?.(next);
109
+ }
110
+
111
+ $effect(() => {
112
+ const selected = value;
113
+ untrack(() => {
114
+ fade?.cancel();
115
+ fade = null;
116
+ const chosen = fadeTo === selected;
117
+ fadeTo = null;
118
+ if (!chosen || !ref) return;
119
+ const shown = Array.from(ref.children).find(
120
+ (el): el is HTMLElement =>
121
+ el instanceof HTMLElement && el.classList.contains('hp-tabs-panel') && el.dataset.value === selected
122
+ );
123
+ if (shown) fade = fadeIn(shown, '--dur-panel');
124
+ });
125
+ });
126
+ </script>
127
+
128
+ <!--
129
+ Bits UI Tabs, rendered through `child` snippets so every element is this
130
+ component's markup and its styles stay scoped. Extra attributes, `class`
131
+ and Bits UI root props (`activationMode`, `loop`, `disabled`,
132
+ `onValueChange`) go to the root `div.hp-tabs`. Parts: `.hp-tabs-list`
133
+ (role tablist), `.hp-tabs-tab` (role tab, with Bits UI's `data-state` and
134
+ `data-value`), `.hp-tabs-panel` (role tabpanel).
135
+ -->
136
+ <BitsTabs.Root bind:value bind:ref onValueChange={changed} {...rest}>
137
+ {#snippet child({ props })}
138
+ <div {...props} class={['hp-tabs', className]}>
139
+ <BitsTabs.List bind:ref={list} aria-label={label}>
140
+ {#snippet child({ props })}
141
+ <div
142
+ {...props}
143
+ class="hp-tabs-list"
144
+ onpointerdowncapture={() => (input = 'pointer')}
145
+ onkeydowncapture={() => (input = 'key')}
146
+ >
147
+ {#each tabs as tab (tab.value)}
148
+ <BitsTabs.Trigger value={tab.value} disabled={tab.disabled}>
149
+ {#snippet child({ props })}
150
+ <button {...props} class="hp-tabs-tab hp-label hp-focus-inset">{tab.label}</button>
151
+ {/snippet}
152
+ </BitsTabs.Trigger>
153
+ {/each}
154
+ </div>
155
+ {/snippet}
156
+ </BitsTabs.List>
157
+ {#each tabs as tab (tab.value)}
158
+ <BitsTabs.Content value={tab.value}>
159
+ {#snippet child({ props })}
160
+ <div {...props} class="hp-tabs-panel">{@render panel(tab)}</div>
161
+ {/snippet}
162
+ </BitsTabs.Content>
163
+ {/each}
164
+ </div>
165
+ {/snippet}
166
+ </BitsTabs.Root>
167
+
168
+ <style>
169
+ /* In a flex or grid parent the tabs never grow wider than their column:
170
+ the list scrolls instead of widening the page. */
171
+ .hp-tabs {
172
+ min-width: 0;
173
+ }
174
+
175
+ /* One row that scrolls sideways when the tabs do not fit. It reaches
176
+ --bleed past the text edge, as the table band does, and each tab pads
177
+ by --bleed, so the first label keeps the page's text edge. */
178
+ .hp-tabs-list {
179
+ display: flex;
180
+ margin-inline: calc(-1 * var(--bleed));
181
+ overflow-x: auto;
182
+ overscroll-behavior-x: contain;
183
+ /* Scrolling a tab into view leaves part of the next one showing. */
184
+ scroll-padding-inline: var(--space-8);
185
+ /* A thin bar on fine pointers, where a wheel cannot scroll sideways
186
+ without one; touch swipes. */
187
+ scrollbar-width: thin;
188
+ }
189
+ @media (pointer: coarse) {
190
+ .hp-tabs-list {
191
+ scrollbar-width: none;
192
+ }
193
+ }
194
+
195
+ /* The visible box is the hit area: --control-height high (44px on
196
+ coarse pointers) and never narrower than --hit-target. */
197
+ .hp-tabs-tab {
198
+ display: inline-flex;
199
+ flex: none;
200
+ align-items: center;
201
+ justify-content: center;
202
+ box-sizing: border-box;
203
+ min-width: var(--hit-target);
204
+ min-height: var(--control-height);
205
+ padding: 0 var(--bleed);
206
+ border: 0;
207
+ background: transparent;
208
+ color: var(--text);
209
+ white-space: nowrap;
210
+ cursor: pointer;
211
+ -webkit-tap-highlight-color: transparent;
212
+ }
213
+
214
+ /* Selected: a 3px titanium edge on the side facing the panel and the
215
+ selected tint, text unchanged. Selection snaps, so nothing on a tab
216
+ transitions: a fading hover tint would also animate the tab an arrow
217
+ key just left while the pointer rests on it. */
218
+ .hp-tabs-tab[data-state='active'] {
219
+ background: var(--surface-selected);
220
+ box-shadow: inset 0 -3px 0 var(--accent);
221
+ }
222
+
223
+ /* Hover: fine pointers only, never on the selected tab. */
224
+ @media (hover: hover) and (pointer: fine) {
225
+ .hp-tabs-tab[data-state='inactive']:hover:not(:disabled) {
226
+ background: var(--surface-hover);
227
+ }
228
+ }
229
+
230
+ /* Press: one step past hover, so it reads on a phone too. */
231
+ .hp-tabs-tab[data-state='inactive']:active:not(:disabled) {
232
+ background: var(--surface-raised);
233
+ }
234
+
235
+ .hp-tabs-tab:disabled {
236
+ color: var(--muted);
237
+ cursor: not-allowed;
238
+ }
239
+
240
+ /* A panel a pointer chose fades in over --dur-panel (see the script);
241
+ otherwise it cuts. Margin, not padding, keeps the panel's focus
242
+ contour clear of the tabs. */
243
+ .hp-tabs-panel {
244
+ margin-top: var(--space-6);
245
+ }
246
+ </style>
@@ -0,0 +1,44 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { Tabs as BitsTabs } from 'bits-ui';
3
+ export type TabsItem = {
4
+ /** Identifies the tab; the bound `value` holds the selected one. */
5
+ value: string;
6
+ /** The caps label. A short fixed string the app defines, never user data. */
7
+ label: string;
8
+ /** Shown in `--muted`; it cannot be selected and arrow keys skip it. */
9
+ disabled?: boolean;
10
+ };
11
+ /** Tabs may carry extra fields (a count, an icon name) that the `panel` snippet reads. */
12
+ export type TabsProps<T extends TabsItem = TabsItem> = Omit<BitsTabs.RootProps, 'value' | 'orientation' | 'child' | 'children'> & {
13
+ tabs: T[];
14
+ /** The selected tab's `value`. Bindable; defaults to the first tab that is not disabled. */
15
+ value?: string;
16
+ /** The tab list's accessible name, for example "Project sections". */
17
+ label: string;
18
+ /** Renders one tab's panel. Every panel stays mounted; the hidden ones carry `hidden`. */
19
+ panel: Snippet<[T]>;
20
+ };
21
+ declare function $$render<T extends TabsItem = TabsItem>(): {
22
+ props: TabsProps<T>;
23
+ exports: {};
24
+ bindings: "value" | "ref";
25
+ slots: {};
26
+ events: {};
27
+ };
28
+ declare class __sveltets_Render<T extends TabsItem = TabsItem> {
29
+ props(): ReturnType<typeof $$render<T>>['props'];
30
+ events(): ReturnType<typeof $$render<T>>['events'];
31
+ slots(): ReturnType<typeof $$render<T>>['slots'];
32
+ bindings(): "value" | "ref";
33
+ exports(): {};
34
+ }
35
+ interface $$IsomorphicComponent {
36
+ new <T extends TabsItem = TabsItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
37
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
38
+ } & ReturnType<__sveltets_Render<T>['exports']>;
39
+ <T extends TabsItem = TabsItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
40
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
41
+ }
42
+ declare const Tabs: $$IsomorphicComponent;
43
+ type Tabs<T extends TabsItem = TabsItem> = InstanceType<typeof Tabs<T>>;
44
+ export default Tabs;
@@ -0,0 +1,40 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ /** The elements a tag renders as. Headings for a tag that names a section. */
6
+ export type TagElement = 'p' | 'span' | 'div' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
7
+
8
+ export type TagProps = HTMLAttributes<HTMLElement> & {
9
+ /**
10
+ * The element to render: `p` (default), or a heading when the tag names
11
+ * a section, with an `id` the section's `aria-labelledby` points at.
12
+ */
13
+ as?: TagElement;
14
+ children?: Snippet;
15
+ };
16
+ </script>
17
+
18
+ <script lang="ts">
19
+ let { as = 'p', class: className, children, ...rest }: TagProps = $props();
20
+ </script>
21
+
22
+ <!--
23
+ A small solid titanium block with black caps (BRIEF, visual rule 2). A tag
24
+ may sit above a block to name it (da3-51); it sets no outer margin, so the
25
+ layout spaces it.
26
+ -->
27
+ <svelte:element this={as} {...rest} class={['hp-tag', 'hp-label', className]}>{@render children?.()}</svelte:element>
28
+
29
+ <style>
30
+ /* The caps type comes from .hp-label. The block never stretches to its
31
+ container, in block, grid and flex layouts alike. */
32
+ .hp-tag {
33
+ justify-self: start;
34
+ width: fit-content;
35
+ /* matches the approved specimen */
36
+ padding: 2px 6px;
37
+ background: var(--accent);
38
+ color: var(--canvas);
39
+ }
40
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ /** The elements a tag renders as. Headings for a tag that names a section. */
4
+ export type TagElement = 'p' | 'span' | 'div' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
5
+ export type TagProps = HTMLAttributes<HTMLElement> & {
6
+ /**
7
+ * The element to render: `p` (default), or a heading when the tag names
8
+ * a section, with an `id` the section's `aria-labelledby` points at.
9
+ */
10
+ as?: TagElement;
11
+ children?: Snippet;
12
+ };
13
+ declare const Tag: import("svelte").Component<TagProps, {}, "">;
14
+ type Tag = ReturnType<typeof Tag>;
15
+ export default Tag;
@@ -0,0 +1,256 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+
4
+ /** The text input types the well suits. For numbers use `text` with `inputmode="numeric"`. */
5
+ export type TextFieldType = 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
6
+
7
+ /** From which width the label sits beside the well: 600px, 900px or 1200px. */
8
+ export type TextFieldBreakpoint = 'medium' | 'expanded' | 'large';
9
+
10
+ /** Attributes and handlers not listed here go to the `<input>`. */
11
+ export type TextFieldProps = Omit<HTMLInputAttributes, 'type' | 'value' | 'children'> & {
12
+ /** The caps field label above the well. Required: a placeholder is never the only label. */
13
+ label: string;
14
+ /** The text. Bindable. */
15
+ value?: string;
16
+ type?: TextFieldType;
17
+ /** Guidance under the well in `--muted`, linked by `aria-describedby`. */
18
+ hint?: string;
19
+ /**
20
+ * The error to show under the well, with an icon, linked by
21
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix the
22
+ * problem. The app validates; the field shows what it is given.
23
+ */
24
+ error?: string;
25
+ /**
26
+ * The label sits above the well by default. `true` puts it beside the
27
+ * well at every width; a breakpoint puts it beside from that width up.
28
+ * The hint and the error stay under the well.
29
+ */
30
+ labelBeside?: boolean | TextFieldBreakpoint;
31
+ /** The `<input>`, for example to focus the first error on submit. */
32
+ ref?: HTMLInputElement | null;
33
+ };
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ import { untrack } from 'svelte';
38
+ import { Label } from 'bits-ui';
39
+ import { fadeIn } from './internal/fade-in.js';
40
+
41
+ const uid = $props.id();
42
+
43
+ let {
44
+ label,
45
+ value = $bindable(''),
46
+ type = 'text',
47
+ hint,
48
+ error,
49
+ labelBeside = false,
50
+ id = uid,
51
+ disabled,
52
+ ref = $bindable(null),
53
+ class: className,
54
+ 'aria-describedby': describedBy,
55
+ 'aria-invalid': ariaInvalid,
56
+ ...rest
57
+ }: TextFieldProps = $props();
58
+
59
+ const hintId = $derived(`${id}-hint`);
60
+ const errorId = $derived(`${id}-error`);
61
+ // The error first, so a screen reader reads it before the hint.
62
+ const describedByIds = $derived(
63
+ [error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
64
+ undefined
65
+ );
66
+
67
+ // ---- Short motion: the error (BRIEF, --dur-message) --------------------
68
+
69
+ // An error that appears after the first render fades in from 0 over
70
+ // --dur-message, and the well's edge fades to --danger with it (the CSS
71
+ // below). An error that clears goes at once, and so does the red edge. A
72
+ // field that renders or mounts with an error, such as one in a dialog
73
+ // that opens, shows it at once. 0ms is a snap.
74
+ let root: HTMLDivElement | null = $state(null);
75
+ let errorShown = untrack(() => !!error);
76
+ $effect(() => {
77
+ const shown = !!error;
78
+ untrack(() => {
79
+ const appeared = shown && !errorShown;
80
+ errorShown = shown;
81
+ const message = appeared ? root?.querySelector(':scope > .error') : null;
82
+ if (message) fadeIn(message, '--dur-message');
83
+ });
84
+ });
85
+ </script>
86
+
87
+ <!--
88
+ A single-line text input in the black well, with a plain caps label above
89
+ it (Bits UI Label), an optional hint and an inline error below. Extra
90
+ attributes (`name`, `inputmode`, `autocomplete`, `enterkeyhint`,
91
+ `required`), handlers and `class` go to the <input>.
92
+ -->
93
+ <div
94
+ bind:this={root}
95
+ class="hp-text-field"
96
+ data-invalid={error ? '' : undefined}
97
+ data-disabled={disabled ? '' : undefined}
98
+ data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
99
+ >
100
+ <Label.Root for={id}>
101
+ {#snippet child({ props })}
102
+ <label {...props} class="hp-label">{label}</label>
103
+ {/snippet}
104
+ </Label.Root>
105
+ <input
106
+ {...rest}
107
+ bind:this={ref}
108
+ bind:value
109
+ {id}
110
+ {type}
111
+ {disabled}
112
+ class={['hp-well', className]}
113
+ aria-describedby={describedByIds}
114
+ aria-invalid={error ? true : ariaInvalid}
115
+ />
116
+ {#if hint}
117
+ <p class="hint hp-caption" id={hintId}>{hint}</p>
118
+ {/if}
119
+ {#if error}
120
+ <p class="error hp-caption" id={errorId}>
121
+ <span class="icon" aria-hidden="true">
122
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
123
+ <path d="M1.5 1.5h13v13h-13z" />
124
+ <path d="M8 4v4M8 10v2" stroke-width="2" />
125
+ </svg>
126
+ </span>
127
+ <span>{error}</span>
128
+ </p>
129
+ {/if}
130
+ </div>
131
+
132
+ <style>
133
+ .hp-text-field {
134
+ display: grid;
135
+ gap: var(--space-1);
136
+ min-width: 0;
137
+ }
138
+ label {
139
+ color: var(--muted);
140
+ }
141
+ input {
142
+ display: block;
143
+ width: 100%;
144
+ min-width: 0;
145
+ }
146
+ input::placeholder {
147
+ color: var(--muted);
148
+ opacity: 1;
149
+ }
150
+
151
+ /* Invalid: the boundary turns --danger (5.9:1 on black), never alone:
152
+ the icon and the message say what is wrong. Keyed on the root's
153
+ data-invalid, set by `error`, so an aria-invalid the app passes
154
+ without a message draws no red border. The edge fades to --danger
155
+ over --dur-message with the message; the transition belongs to this
156
+ rule, so the edge goes back at once when the error clears. The hover
157
+ fill keeps base.css's timing. */
158
+ [data-invalid] input {
159
+ border-color: var(--danger);
160
+ transition:
161
+ background-color var(--dur-hover) var(--settle),
162
+ border-color var(--dur-message) var(--settle);
163
+ }
164
+ [data-invalid] input:disabled {
165
+ border-color: var(--border);
166
+ }
167
+
168
+ .hint {
169
+ margin: 0;
170
+ color: var(--muted);
171
+ }
172
+ .error {
173
+ display: flex;
174
+ gap: var(--space-2);
175
+ margin: 0;
176
+ color: var(--danger);
177
+ }
178
+ /* The first message starts 8px under the well, clear of the focus
179
+ contour (2px, drawn 4px outside the well). */
180
+ input + .hint,
181
+ input + .error {
182
+ margin-top: var(--space-1);
183
+ }
184
+ /* One line box high, so the icon centres on the first line of the
185
+ message however it wraps. */
186
+ .icon {
187
+ display: flex;
188
+ flex: none;
189
+ align-items: center;
190
+ height: 1lh;
191
+ }
192
+ .icon svg {
193
+ display: block;
194
+ }
195
+
196
+ /* Beside the well, the label keeps its one line in the first column; the
197
+ well, hint and error share the second. */
198
+ [data-label-beside='always'] {
199
+ grid-template-columns: auto minmax(0, 1fr);
200
+ column-gap: var(--space-2);
201
+ }
202
+ [data-label-beside='always'] label {
203
+ grid-row: 1;
204
+ align-self: center;
205
+ white-space: nowrap;
206
+ }
207
+ [data-label-beside='always'] input,
208
+ [data-label-beside='always'] p {
209
+ grid-column: 2;
210
+ }
211
+ @media (min-width: 600px) {
212
+ [data-label-beside='medium'] {
213
+ grid-template-columns: auto minmax(0, 1fr);
214
+ column-gap: var(--space-2);
215
+ }
216
+ [data-label-beside='medium'] label {
217
+ grid-row: 1;
218
+ align-self: center;
219
+ white-space: nowrap;
220
+ }
221
+ [data-label-beside='medium'] input,
222
+ [data-label-beside='medium'] p {
223
+ grid-column: 2;
224
+ }
225
+ }
226
+ @media (min-width: 900px) {
227
+ [data-label-beside='expanded'] {
228
+ grid-template-columns: auto minmax(0, 1fr);
229
+ column-gap: var(--space-2);
230
+ }
231
+ [data-label-beside='expanded'] label {
232
+ grid-row: 1;
233
+ align-self: center;
234
+ white-space: nowrap;
235
+ }
236
+ [data-label-beside='expanded'] input,
237
+ [data-label-beside='expanded'] p {
238
+ grid-column: 2;
239
+ }
240
+ }
241
+ @media (min-width: 1200px) {
242
+ [data-label-beside='large'] {
243
+ grid-template-columns: auto minmax(0, 1fr);
244
+ column-gap: var(--space-2);
245
+ }
246
+ [data-label-beside='large'] label {
247
+ grid-row: 1;
248
+ align-self: center;
249
+ white-space: nowrap;
250
+ }
251
+ [data-label-beside='large'] input,
252
+ [data-label-beside='large'] p {
253
+ grid-column: 2;
254
+ }
255
+ }
256
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ /** The text input types the well suits. For numbers use `text` with `inputmode="numeric"`. */
3
+ export type TextFieldType = 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
4
+ /** From which width the label sits beside the well: 600px, 900px or 1200px. */
5
+ export type TextFieldBreakpoint = 'medium' | 'expanded' | 'large';
6
+ /** Attributes and handlers not listed here go to the `<input>`. */
7
+ export type TextFieldProps = Omit<HTMLInputAttributes, 'type' | 'value' | 'children'> & {
8
+ /** The caps field label above the well. Required: a placeholder is never the only label. */
9
+ label: string;
10
+ /** The text. Bindable. */
11
+ value?: string;
12
+ type?: TextFieldType;
13
+ /** Guidance under the well in `--muted`, linked by `aria-describedby`. */
14
+ hint?: string;
15
+ /**
16
+ * The error to show under the well, with an icon, linked by
17
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix the
18
+ * problem. The app validates; the field shows what it is given.
19
+ */
20
+ error?: string;
21
+ /**
22
+ * The label sits above the well by default. `true` puts it beside the
23
+ * well at every width; a breakpoint puts it beside from that width up.
24
+ * The hint and the error stay under the well.
25
+ */
26
+ labelBeside?: boolean | TextFieldBreakpoint;
27
+ /** The `<input>`, for example to focus the first error on submit. */
28
+ ref?: HTMLInputElement | null;
29
+ };
30
+ declare const TextField: import("svelte").Component<TextFieldProps, {}, "value" | "ref">;
31
+ type TextField = ReturnType<typeof TextField>;
32
+ export default TextField;