@mystravil/hardpoint 0.1.0 → 0.2.1
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 +42 -18
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -346
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +174 -174
- package/dist/components/Checkbox.svelte +235 -198
- package/dist/components/Checkbox.svelte.d.ts +9 -2
- package/dist/components/CheckboxGroup.svelte +207 -99
- package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
- package/dist/components/Combobox.svelte +901 -824
- package/dist/components/Combobox.svelte.d.ts +10 -1
- package/dist/components/Dialog.svelte +359 -356
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +258 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +18 -2
- package/dist/components/NativeSelect.svelte.d.ts +6 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -405
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -117
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -256
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/fonts.css +71 -71
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/tokens.css +283 -279
- package/package.json +81 -76
|
@@ -1,246 +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>
|
|
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>
|
|
@@ -1,40 +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>
|
|
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>
|