@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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +346 -0
- package/dist/components/AppShell.svelte +698 -0
- package/dist/components/AppShell.svelte.d.ts +77 -0
- package/dist/components/Button.svelte +174 -0
- package/dist/components/Button.svelte.d.ts +20 -0
- package/dist/components/Checkbox.svelte +198 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/CheckboxGroup.svelte +99 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
- package/dist/components/Combobox.svelte +824 -0
- package/dist/components/Combobox.svelte.d.ts +63 -0
- package/dist/components/Dialog.svelte +356 -0
- package/dist/components/Dialog.svelte.d.ts +43 -0
- package/dist/components/Disclosure.svelte +201 -0
- package/dist/components/Disclosure.svelte.d.ts +27 -0
- package/dist/components/EmptyState.svelte +88 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/ErrorState.svelte +135 -0
- package/dist/components/ErrorState.svelte.d.ts +26 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/LoadRegion.svelte +219 -0
- package/dist/components/LoadRegion.svelte.d.ts +27 -0
- package/dist/components/MediaFrame.svelte +110 -0
- package/dist/components/MediaFrame.svelte.d.ts +20 -0
- package/dist/components/MediaImage.svelte +256 -0
- package/dist/components/MediaImage.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +258 -0
- package/dist/components/Menu.svelte.d.ts +36 -0
- package/dist/components/MenuCheckboxItem.svelte +119 -0
- package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
- package/dist/components/MenuGroup.svelte +50 -0
- package/dist/components/MenuGroup.svelte.d.ts +15 -0
- package/dist/components/MenuItem.svelte +94 -0
- package/dist/components/MenuItem.svelte.d.ts +19 -0
- package/dist/components/MenuRadioGroup.svelte +62 -0
- package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/components/MenuRadioItem.svelte +110 -0
- package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
- package/dist/components/MenuSeparator.svelte +28 -0
- package/dist/components/MenuSeparator.svelte.d.ts +6 -0
- package/dist/components/NativeSelect.svelte +119 -0
- package/dist/components/NativeSelect.svelte.d.ts +46 -0
- package/dist/components/Pagination.svelte +132 -0
- package/dist/components/Pagination.svelte.d.ts +18 -0
- package/dist/components/Popover.svelte +222 -0
- package/dist/components/Popover.svelte.d.ts +35 -0
- package/dist/components/Progress.svelte +157 -0
- package/dist/components/Progress.svelte.d.ts +34 -0
- package/dist/components/Select.svelte +405 -0
- package/dist/components/Select.svelte.d.ts +42 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +117 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +253 -0
- package/dist/components/Stat.svelte.d.ts +46 -0
- package/dist/components/Steps.svelte +189 -0
- package/dist/components/Steps.svelte.d.ts +31 -0
- package/dist/components/Switch.svelte +222 -0
- package/dist/components/Switch.svelte.d.ts +18 -0
- package/dist/components/Table.svelte +504 -0
- package/dist/components/Table.svelte.d.ts +92 -0
- package/dist/components/Tabs.svelte +246 -0
- package/dist/components/Tabs.svelte.d.ts +44 -0
- package/dist/components/Tag.svelte +40 -0
- package/dist/components/Tag.svelte.d.ts +15 -0
- package/dist/components/TextField.svelte +256 -0
- package/dist/components/TextField.svelte.d.ts +32 -0
- package/dist/components/Timeline.svelte +156 -0
- package/dist/components/Timeline.svelte.d.ts +30 -0
- package/dist/components/Toaster.svelte +411 -0
- package/dist/components/Toaster.svelte.d.ts +11 -0
- package/dist/components/Tooltip.svelte +200 -0
- package/dist/components/Tooltip.svelte.d.ts +38 -0
- package/dist/components/TooltipGroup.svelte +46 -0
- package/dist/components/TooltipGroup.svelte.d.ts +29 -0
- package/dist/components/internal/Scrim.svelte +52 -0
- package/dist/components/internal/Scrim.svelte.d.ts +15 -0
- package/dist/components/internal/Sheet.svelte +263 -0
- package/dist/components/internal/Sheet.svelte.d.ts +36 -0
- package/dist/components/internal/fade-in.d.ts +10 -0
- package/dist/components/internal/fade-in.js +20 -0
- package/dist/components/internal/resolve.d.ts +8 -0
- package/dist/components/internal/resolve.js +247 -0
- package/dist/components/internal/swipe-dismiss.d.ts +47 -0
- package/dist/components/internal/swipe-dismiss.js +220 -0
- package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
- package/dist/components/internal/visual-viewport.svelte.js +79 -0
- package/dist/components/toast.svelte.d.ts +57 -0
- package/dist/components/toast.svelte.js +84 -0
- package/dist/fonts/OFL-geist-pixel.txt +92 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/fonts/geist-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-pixel-square.woff2 +0 -0
- package/dist/fonts.css +71 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +39 -0
- package/dist/motion/Motion.svelte +15 -0
- package/dist/motion/Motion.svelte.d.ts +9 -0
- package/dist/motion/context.d.ts +18 -0
- package/dist/motion/context.js +26 -0
- package/dist/motion/index.d.ts +3 -0
- package/dist/motion/index.js +3 -0
- package/dist/motion/transitions.d.ts +31 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/tokens.css +279 -0
- 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;
|