@mystravil/hardpoint 0.0.0-stage → 0.2.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 (126) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +625 -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 +235 -0
  10. package/dist/components/Checkbox.svelte.d.ts +23 -0
  11. package/dist/components/CheckboxGroup.svelte +207 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
  13. package/dist/components/Combobox.svelte +901 -0
  14. package/dist/components/Combobox.svelte.d.ts +72 -0
  15. package/dist/components/Dialog.svelte +359 -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/ImageViewer.svelte +764 -0
  24. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  25. package/dist/components/KeyValueList.svelte +114 -0
  26. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  27. package/dist/components/ListDetail.svelte +218 -0
  28. package/dist/components/ListDetail.svelte.d.ts +37 -0
  29. package/dist/components/LoadRegion.svelte +219 -0
  30. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  31. package/dist/components/MediaFrame.svelte +110 -0
  32. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  33. package/dist/components/MediaImage.svelte +256 -0
  34. package/dist/components/MediaImage.svelte.d.ts +16 -0
  35. package/dist/components/Menu.svelte +258 -0
  36. package/dist/components/Menu.svelte.d.ts +36 -0
  37. package/dist/components/MenuCheckboxItem.svelte +119 -0
  38. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  39. package/dist/components/MenuGroup.svelte +50 -0
  40. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  41. package/dist/components/MenuItem.svelte +94 -0
  42. package/dist/components/MenuItem.svelte.d.ts +19 -0
  43. package/dist/components/MenuRadioGroup.svelte +62 -0
  44. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  45. package/dist/components/MenuRadioItem.svelte +110 -0
  46. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  47. package/dist/components/MenuSeparator.svelte +28 -0
  48. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  49. package/dist/components/Meter.svelte +283 -0
  50. package/dist/components/Meter.svelte.d.ts +66 -0
  51. package/dist/components/NativeSelect.svelte +135 -0
  52. package/dist/components/NativeSelect.svelte.d.ts +52 -0
  53. package/dist/components/Notice.svelte +262 -0
  54. package/dist/components/Notice.svelte.d.ts +30 -0
  55. package/dist/components/Pagination.svelte +132 -0
  56. package/dist/components/Pagination.svelte.d.ts +18 -0
  57. package/dist/components/Popover.svelte +222 -0
  58. package/dist/components/Popover.svelte.d.ts +35 -0
  59. package/dist/components/Progress.svelte +157 -0
  60. package/dist/components/Progress.svelte.d.ts +34 -0
  61. package/dist/components/RadioGroup.svelte +440 -0
  62. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  63. package/dist/components/Select.svelte +471 -0
  64. package/dist/components/Select.svelte.d.ts +48 -0
  65. package/dist/components/Skeleton.svelte +149 -0
  66. package/dist/components/Skeleton.svelte.d.ts +24 -0
  67. package/dist/components/Spinner.svelte +118 -0
  68. package/dist/components/Spinner.svelte.d.ts +13 -0
  69. package/dist/components/Stat.svelte +253 -0
  70. package/dist/components/Stat.svelte.d.ts +46 -0
  71. package/dist/components/Steps.svelte +189 -0
  72. package/dist/components/Steps.svelte.d.ts +31 -0
  73. package/dist/components/Switch.svelte +222 -0
  74. package/dist/components/Switch.svelte.d.ts +18 -0
  75. package/dist/components/Table.svelte +504 -0
  76. package/dist/components/Table.svelte.d.ts +92 -0
  77. package/dist/components/Tabs.svelte +246 -0
  78. package/dist/components/Tabs.svelte.d.ts +44 -0
  79. package/dist/components/Tag.svelte +40 -0
  80. package/dist/components/Tag.svelte.d.ts +15 -0
  81. package/dist/components/TextArea.svelte +272 -0
  82. package/dist/components/TextArea.svelte.d.ts +28 -0
  83. package/dist/components/TextField.svelte +256 -0
  84. package/dist/components/TextField.svelte.d.ts +32 -0
  85. package/dist/components/Timeline.svelte +156 -0
  86. package/dist/components/Timeline.svelte.d.ts +30 -0
  87. package/dist/components/Toaster.svelte +411 -0
  88. package/dist/components/Toaster.svelte.d.ts +11 -0
  89. package/dist/components/Tooltip.svelte +200 -0
  90. package/dist/components/Tooltip.svelte.d.ts +38 -0
  91. package/dist/components/TooltipGroup.svelte +46 -0
  92. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  93. package/dist/components/internal/Scrim.svelte +52 -0
  94. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  95. package/dist/components/internal/Sheet.svelte +263 -0
  96. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  97. package/dist/components/internal/fade-in.d.ts +10 -0
  98. package/dist/components/internal/fade-in.js +20 -0
  99. package/dist/components/internal/groups.d.ts +14 -0
  100. package/dist/components/internal/groups.js +23 -0
  101. package/dist/components/internal/resolve.d.ts +8 -0
  102. package/dist/components/internal/resolve.js +247 -0
  103. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  104. package/dist/components/internal/swipe-dismiss.js +220 -0
  105. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  106. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  107. package/dist/components/toast.svelte.d.ts +57 -0
  108. package/dist/components/toast.svelte.js +84 -0
  109. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  110. package/dist/fonts/OFL.txt +93 -0
  111. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  112. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  113. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  114. package/dist/fonts.css +71 -0
  115. package/dist/index.d.ts +45 -0
  116. package/dist/index.js +45 -0
  117. package/dist/motion/Motion.svelte +15 -0
  118. package/dist/motion/Motion.svelte.d.ts +9 -0
  119. package/dist/motion/context.d.ts +18 -0
  120. package/dist/motion/context.js +26 -0
  121. package/dist/motion/index.d.ts +3 -0
  122. package/dist/motion/index.js +3 -0
  123. package/dist/motion/transitions.d.ts +31 -0
  124. package/dist/motion/transitions.js +112 -0
  125. package/dist/tokens.css +283 -0
  126. 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,272 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
3
+
4
+ /** Attributes and handlers not listed here go to the `<textarea>`. */
5
+ export type TextAreaProps = Omit<HTMLTextareaAttributes, 'value' | 'children' | 'rows'> & {
6
+ /** The caps field label above the well. Required: a placeholder is never the only label. */
7
+ label: string;
8
+ /** The text. Bindable. */
9
+ value?: string;
10
+ /** Guidance under the well in `--muted`, linked by `aria-describedby`. */
11
+ hint?: string;
12
+ /**
13
+ * The error to show under the well, with an icon, linked by
14
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix the
15
+ * problem. The app validates; the field shows what it is given.
16
+ */
17
+ error?: string;
18
+ /** The height of the empty well, in lines. */
19
+ rows?: number;
20
+ /**
21
+ * The well grows with its text up to this many lines, then scrolls
22
+ * inside. Never less than `rows`.
23
+ */
24
+ maxRows?: number;
25
+ /** The `<textarea>`, for example to focus the first error on submit. */
26
+ ref?: HTMLTextAreaElement | null;
27
+ };
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ import { untrack } from 'svelte';
32
+ import { Label } from 'bits-ui';
33
+ import { fadeIn } from './internal/fade-in.js';
34
+
35
+ const uid = $props.id();
36
+
37
+ let {
38
+ label,
39
+ value = $bindable(''),
40
+ hint,
41
+ error,
42
+ rows = 3,
43
+ maxRows = 10,
44
+ id = uid,
45
+ disabled,
46
+ readonly,
47
+ ref = $bindable(null),
48
+ class: className,
49
+ 'aria-describedby': describedBy,
50
+ 'aria-invalid': ariaInvalid,
51
+ ...rest
52
+ }: TextAreaProps = $props();
53
+
54
+ const hintId = $derived(`${id}-hint`);
55
+ const errorId = $derived(`${id}-error`);
56
+ // The error first, so a screen reader reads it before the hint.
57
+ const describedByIds = $derived(
58
+ [error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
59
+ undefined
60
+ );
61
+
62
+ // ---- Short motion: the error (BRIEF, --dur-message) --------------------
63
+
64
+ // As TextField: an error that appears after the first render fades in
65
+ // from 0 over --dur-message, and the well's edge fades to --danger with
66
+ // it (the CSS below). An error that clears goes at once, and so does the
67
+ // red edge. A field that renders or mounts with an error shows it at once.
68
+ let root: HTMLDivElement | null = $state(null);
69
+ let errorShown = untrack(() => !!error);
70
+ $effect(() => {
71
+ const shown = !!error;
72
+ untrack(() => {
73
+ const appeared = shown && !errorShown;
74
+ errorShown = shown;
75
+ const message = appeared ? root?.querySelector(':scope > .error') : null;
76
+ if (message) fadeIn(message, '--dur-message');
77
+ });
78
+ });
79
+
80
+ // ---- Growth ------------------------------------------------------------
81
+
82
+ // Where the browser has field-sizing (Chromium 123+), CSS alone sizes the
83
+ // well between its rows and maxRows heights. Elsewhere the height comes
84
+ // from scrollHeight whenever the text, the line counts or the width
85
+ // change, written through element.style, never a style attribute.
86
+ const sizesItself = typeof CSS !== 'undefined' && CSS.supports('field-sizing', 'content');
87
+
88
+ function fit(field: HTMLTextAreaElement) {
89
+ // The well collapses to its content to measure it. Holding the root's
90
+ // height meanwhile keeps the page from getting shorter, which would
91
+ // move its scroll position. Without a scrollbar while it measures, the
92
+ // text wraps at the width it has once it fits.
93
+ if (root) root.style.minHeight = `${root.offsetHeight}px`;
94
+ field.style.overflowY = 'hidden';
95
+ field.style.height = 'auto';
96
+ const borders = field.offsetHeight - field.clientHeight;
97
+ field.style.height = `${field.scrollHeight + borders}px`;
98
+ field.style.overflowY = '';
99
+ if (root) root.style.minHeight = '';
100
+ }
101
+
102
+ $effect(() => {
103
+ const field = ref;
104
+ if (sizesItself || !field) return;
105
+ // Read so the effect runs again when they change. A new `style` from
106
+ // the app replaces the element's inline height, so it refits too.
107
+ void [value, rows, maxRows, rest.style];
108
+ fit(field);
109
+ });
110
+
111
+ $effect(() => {
112
+ const field = ref;
113
+ if (sizesItself || !field) return;
114
+ let width = field.clientWidth;
115
+ const observer = new ResizeObserver(() => {
116
+ if (field.clientWidth === width) return;
117
+ width = field.clientWidth;
118
+ fit(field);
119
+ });
120
+ observer.observe(field);
121
+ return () => observer.disconnect();
122
+ });
123
+ </script>
124
+
125
+ <!--
126
+ Multi-line text in the black well, with a plain caps label above it (Bits
127
+ UI Label), an optional hint and an inline error below. The well starts
128
+ `rows` lines high, grows with its text up to `maxRows` lines, then scrolls
129
+ inside. Extra attributes (`name`, `placeholder`, `required`, `maxlength`,
130
+ `autocomplete`, `enterkeyhint`), handlers and `class` go to the
131
+ <textarea>. The textarea is never re-created and its value is written
132
+ only when it differs, so the text, the caret and focus survive any
133
+ re-render around it.
134
+ -->
135
+ <div
136
+ bind:this={root}
137
+ class="hp-text-area"
138
+ data-invalid={error ? '' : undefined}
139
+ data-disabled={disabled ? '' : undefined}
140
+ data-readonly={readonly ? '' : undefined}
141
+ style:--text-area-rows={Math.max(1, rows)}
142
+ style:--text-area-max-rows={Math.max(1, maxRows)}
143
+ >
144
+ <Label.Root for={id}>
145
+ {#snippet child({ props })}
146
+ <label {...props} class="hp-label">{label}</label>
147
+ {/snippet}
148
+ </Label.Root>
149
+ <textarea
150
+ {...rest}
151
+ bind:this={ref}
152
+ bind:value
153
+ {id}
154
+ rows={Math.max(1, rows)}
155
+ {disabled}
156
+ {readonly}
157
+ class={['hp-well', className]}
158
+ aria-describedby={describedByIds}
159
+ aria-invalid={error ? true : ariaInvalid}
160
+ ></textarea>
161
+ {#if hint}
162
+ <p class="hint hp-caption" id={hintId}>{hint}</p>
163
+ {/if}
164
+ {#if error}
165
+ <p class="error hp-caption" id={errorId}>
166
+ <span class="icon" aria-hidden="true">
167
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
168
+ <path d="M1.5 1.5h13v13h-13z" />
169
+ <path d="M8 4v4M8 10v2" stroke-width="2" />
170
+ </svg>
171
+ </span>
172
+ <span>{error}</span>
173
+ </p>
174
+ {/if}
175
+ </div>
176
+
177
+ <style>
178
+ .hp-text-area {
179
+ display: grid;
180
+ gap: var(--space-1);
181
+ min-width: 0;
182
+ }
183
+ label {
184
+ color: var(--muted);
185
+ }
186
+
187
+ /* The well, in lines of 24px. Its heights are the line count times the
188
+ line, plus 12px of padding at the top and bottom and the two 1px
189
+ borders: 98px empty and 266px at most by default. The root sets the
190
+ line counts from `rows` and `maxRows`; the fallbacks are the defaults,
191
+ for a page whose policy blocks the style attribute of server-rendered
192
+ markup. The text is 16px at every width, from .hp-well. */
193
+ textarea {
194
+ display: block;
195
+ width: 100%;
196
+ min-width: 0;
197
+ height: auto;
198
+ min-height: calc(var(--text-area-rows, 3) * 24px + 2 * var(--space-3) + 2px);
199
+ max-height: calc(var(--text-area-max-rows, 10) * 24px + 2 * var(--space-3) + 2px);
200
+ padding: var(--space-3);
201
+ line-height: 24px;
202
+ field-sizing: content;
203
+ resize: none;
204
+ overflow-y: auto;
205
+ /* Once it scrolls, the browser keeps the caret's line clear of the
206
+ padding, so the line being typed never sits on the edge. */
207
+ scroll-padding-block: var(--space-3);
208
+ /* As the other scrolling parts: a thin bar on fine pointers; touch
209
+ shows its own while it scrolls. */
210
+ scrollbar-width: thin;
211
+ }
212
+ textarea::placeholder {
213
+ color: var(--muted);
214
+ opacity: 1;
215
+ }
216
+
217
+ /* Read-only: muted text as when disabled, and no hover fill, because
218
+ nothing here can change. It keeps the 3:1 boundary: the field still
219
+ takes focus and its text can be selected and copied, so it stays a
220
+ control (WCAG 1.4.11 exempts only inactive ones). */
221
+ textarea[readonly] {
222
+ color: var(--muted);
223
+ }
224
+ @media (hover: hover) and (pointer: fine) {
225
+ textarea[readonly]:hover {
226
+ background-color: var(--canvas);
227
+ }
228
+ }
229
+
230
+ /* Invalid: as TextField. The boundary turns --danger, never alone: the
231
+ icon and the message say what is wrong. Keyed on the root's
232
+ data-invalid, set by `error`. The edge fades to --danger over
233
+ --dur-message with the message and goes back at once when the error
234
+ clears. A disabled well keeps --border. */
235
+ [data-invalid] textarea {
236
+ border-color: var(--danger);
237
+ transition:
238
+ background-color var(--dur-hover) var(--settle),
239
+ border-color var(--dur-message) var(--settle);
240
+ }
241
+ [data-invalid] textarea:disabled {
242
+ border-color: var(--border);
243
+ }
244
+
245
+ .hint {
246
+ margin: 0;
247
+ color: var(--muted);
248
+ }
249
+ .error {
250
+ display: flex;
251
+ gap: var(--space-2);
252
+ margin: 0;
253
+ color: var(--danger);
254
+ }
255
+ /* The first message starts 8px under the well, clear of the focus
256
+ contour (2px, drawn 4px outside the well). */
257
+ textarea + .hint,
258
+ textarea + .error {
259
+ margin-top: var(--space-1);
260
+ }
261
+ /* One line box high, so the icon centres on the first line of the
262
+ message however it wraps. */
263
+ .icon {
264
+ display: flex;
265
+ flex: none;
266
+ align-items: center;
267
+ height: 1lh;
268
+ }
269
+ .icon svg {
270
+ display: block;
271
+ }
272
+ </style>
@@ -0,0 +1,28 @@
1
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
2
+ /** Attributes and handlers not listed here go to the `<textarea>`. */
3
+ export type TextAreaProps = Omit<HTMLTextareaAttributes, 'value' | 'children' | 'rows'> & {
4
+ /** The caps field label above the well. Required: a placeholder is never the only label. */
5
+ label: string;
6
+ /** The text. Bindable. */
7
+ value?: string;
8
+ /** Guidance under the well in `--muted`, linked by `aria-describedby`. */
9
+ hint?: string;
10
+ /**
11
+ * The error to show under the well, with an icon, linked by
12
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix the
13
+ * problem. The app validates; the field shows what it is given.
14
+ */
15
+ error?: string;
16
+ /** The height of the empty well, in lines. */
17
+ rows?: number;
18
+ /**
19
+ * The well grows with its text up to this many lines, then scrolls
20
+ * inside. Never less than `rows`.
21
+ */
22
+ maxRows?: number;
23
+ /** The `<textarea>`, for example to focus the first error on submit. */
24
+ ref?: HTMLTextAreaElement | null;
25
+ };
26
+ declare const TextArea: import("svelte").Component<TextAreaProps, {}, "value" | "ref">;
27
+ type TextArea = ReturnType<typeof TextArea>;
28
+ export default TextArea;