@lyeve-labs/ui-kit 0.23.3 → 0.25.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/README.md +42 -0
- package/dist/components/AccordionItem.svelte +3 -5
- package/dist/components/AccountMenu.svelte +4 -3
- package/dist/components/Alert.svelte +2 -1
- package/dist/components/AppShell.svelte +166 -54
- package/dist/components/AppShell.svelte.d.ts +41 -6
- package/dist/components/Autocomplete.svelte +3 -2
- package/dist/components/Badge.svelte +10 -5
- package/dist/components/Banner.svelte +2 -1
- package/dist/components/Breadcrumb.svelte +7 -2
- package/dist/components/Button.svelte +15 -2
- package/dist/components/Card.svelte +1 -1
- package/dist/components/Collapsible.svelte +5 -4
- package/dist/components/CopyButton.svelte +2 -1
- package/dist/components/DatePicker.svelte +77 -62
- package/dist/components/Drawer.svelte +6 -5
- package/dist/components/Dropdown.svelte +5 -2
- package/dist/components/FileInput.svelte +1 -1
- package/dist/components/Modal.svelte +6 -3
- package/dist/components/MultiSelect.svelte +8 -6
- package/dist/components/NumberInput.svelte +2 -2
- package/dist/components/PageHeader.svelte +13 -1
- package/dist/components/PageShell.svelte +62 -7
- package/dist/components/PageShell.svelte.d.ts +27 -1
- package/dist/components/Pagination.svelte +7 -2
- package/dist/components/PasswordInput.svelte +2 -1
- package/dist/components/Progress.svelte +1 -1
- package/dist/components/SearchInput.svelte +3 -2
- package/dist/components/SectionHeading.svelte +10 -1
- package/dist/components/SegmentedControl.svelte +101 -27
- package/dist/components/SegmentedControl.svelte.d.ts +9 -0
- package/dist/components/Select.svelte +4 -3
- package/dist/components/SidebarNav.svelte +26 -17
- package/dist/components/StepIndicator.svelte +7 -9
- package/dist/components/Table.svelte +39 -29
- package/dist/components/Tabs.svelte +128 -24
- package/dist/components/Tag.svelte +2 -1
- package/dist/components/ThemeToggle.svelte +2 -1
- package/dist/components/Toaster.svelte +6 -2
- package/dist/components/Toggle.svelte +23 -5
- package/dist/components/Tooltip.svelte +10 -5
- package/dist/components/TreeView.svelte +2 -2
- package/dist/components/dialog/Dialog.svelte +15 -32
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/internal/choice.js +10 -3
- package/dist/internal/field.js +3 -3
- package/dist/internal/layout.d.ts +10 -1
- package/dist/internal/layout.js +10 -1
- package/dist/internal/panel.d.ts +13 -3
- package/dist/internal/panel.js +16 -5
- package/dist/internal/scroll-edges.d.ts +33 -0
- package/dist/internal/scroll-edges.js +33 -0
- package/dist/internal/touch.d.ts +28 -0
- package/dist/internal/touch.js +28 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +165 -0
- package/dist/styles/theme.css +131 -63
- package/package.json +1 -1
- package/src/lib/styles/theme.css +131 -63
|
@@ -39,7 +39,16 @@
|
|
|
39
39
|
<h3 class={sectionHeading(3, variant)}>{@render children()}</h3>
|
|
40
40
|
{/if}
|
|
41
41
|
|
|
42
|
+
<!-- Wraps as PageHeader's slot does: beside the heading while it fits,
|
|
43
|
+
under it at the end edge when not, and inside itself when wider than
|
|
44
|
+
the row. Five filter chips in this slot ran 212px past a phone's edge
|
|
45
|
+
while it was `shrink-0`. -->
|
|
42
46
|
{#if actions}
|
|
43
|
-
<div
|
|
47
|
+
<div
|
|
48
|
+
data-testid="section-actions"
|
|
49
|
+
class="ms-auto flex min-w-0 flex-wrap items-center justify-end gap-2"
|
|
50
|
+
>
|
|
51
|
+
{@render actions()}
|
|
52
|
+
</div>
|
|
44
53
|
{/if}
|
|
45
54
|
</div>
|
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
label: string;
|
|
10
10
|
/** A lucide component. The control sizes it, so the caller does not guess. */
|
|
11
11
|
icon?: Component<{ size?: number; class?: string }>;
|
|
12
|
+
/**
|
|
13
|
+
* Renders the segment as a link to this URL instead of a radio. Every
|
|
14
|
+
* option carries one or none does; the modes cannot be mixed in one
|
|
15
|
+
* control. The chosen segment is still the one whose value matches
|
|
16
|
+
* `value`, and it carries aria-current="page", so a page can keep its
|
|
17
|
+
* choice in the URL and the control works before any script has loaded.
|
|
18
|
+
*/
|
|
19
|
+
href?: string;
|
|
12
20
|
}
|
|
13
21
|
</script>
|
|
14
22
|
|
|
15
23
|
<script lang="ts" generics="T extends string">
|
|
16
24
|
import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
|
|
25
|
+
import { safeHref } from '../internal/href.js';
|
|
17
26
|
|
|
18
27
|
type Size = 'sm' | 'md';
|
|
19
28
|
|
|
@@ -34,6 +43,7 @@
|
|
|
34
43
|
size?: Size;
|
|
35
44
|
disabled?: boolean;
|
|
36
45
|
class?: string;
|
|
46
|
+
/** Value mode only. A link segment reports nothing; the page it opens does. */
|
|
37
47
|
onchange?: (value: T) => void;
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -62,8 +72,32 @@
|
|
|
62
72
|
|
|
63
73
|
let rootEl: HTMLDivElement | undefined = $state();
|
|
64
74
|
|
|
75
|
+
/*
|
|
76
|
+
* Link mode when every option carries an href. A row that mixes the two is
|
|
77
|
+
* refused outright rather than rendered as best it can: a radio beside two
|
|
78
|
+
* links would report a choice that the URL never learns about, and the
|
|
79
|
+
* caller has no way to see that from the screen.
|
|
80
|
+
*/
|
|
81
|
+
const linked = $derived.by(() => {
|
|
82
|
+
const count = options.filter((o) => o.href !== undefined).length;
|
|
83
|
+
if (count !== 0 && count !== options.length) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`SegmentedControl: every option carries href or none does, ${count} of ${options.length} did`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return count !== 0;
|
|
89
|
+
});
|
|
90
|
+
|
|
65
91
|
const selectedIndex = $derived(options.findIndex((o) => o.value === value));
|
|
66
92
|
|
|
93
|
+
/*
|
|
94
|
+
* In link mode the arrows move focus without moving the choice, since the
|
|
95
|
+
* choice is the URL and only following a link changes it. The tab stop
|
|
96
|
+
* travels with focus while it is inside the group, and returns to the chosen
|
|
97
|
+
* segment when focus leaves, so Tab re-enters on the one that is current.
|
|
98
|
+
*/
|
|
99
|
+
let roving: number | null = $state(null);
|
|
100
|
+
|
|
67
101
|
/*
|
|
68
102
|
* The group holds ONE tab stop, which is the whole reason this is not a row
|
|
69
103
|
* of buttons: a picker of five themes cost five presses of Tab to step over.
|
|
@@ -72,10 +106,15 @@
|
|
|
72
106
|
* drop the control out of the tab order entirely, so the first segment holds
|
|
73
107
|
* the stop until something is chosen.
|
|
74
108
|
*/
|
|
75
|
-
const tabStop = $derived(selectedIndex === -1 ? 0 : selectedIndex);
|
|
109
|
+
const tabStop = $derived(roving ?? (selectedIndex === -1 ? 0 : selectedIndex));
|
|
76
110
|
|
|
77
111
|
function segmentAt(index: number): HTMLElement | undefined {
|
|
78
|
-
return rootEl?.querySelectorAll<HTMLElement>('[
|
|
112
|
+
return rootEl?.querySelectorAll<HTMLElement>('[data-segment]')[index];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onGroupFocusOut(e: FocusEvent) {
|
|
116
|
+
if (rootEl && e.relatedTarget instanceof Node && rootEl.contains(e.relatedTarget)) return;
|
|
117
|
+
roving = null;
|
|
79
118
|
}
|
|
80
119
|
|
|
81
120
|
function choose(index: number) {
|
|
@@ -123,7 +162,13 @@
|
|
|
123
162
|
}
|
|
124
163
|
// The arrows scroll the page, and Home and End jump it to the ends.
|
|
125
164
|
e.preventDefault();
|
|
126
|
-
|
|
165
|
+
if (linked) {
|
|
166
|
+
if (disabled) return;
|
|
167
|
+
roving = next;
|
|
168
|
+
segmentAt(next)?.focus();
|
|
169
|
+
} else {
|
|
170
|
+
choose(next);
|
|
171
|
+
}
|
|
127
172
|
}
|
|
128
173
|
</script>
|
|
129
174
|
|
|
@@ -135,10 +180,14 @@
|
|
|
135
180
|
<span class={FIELD_LABEL}>{label}</span>
|
|
136
181
|
{/if}
|
|
137
182
|
|
|
183
|
+
<!-- A set of links is a group, not a radiogroup: a link is not a radio and
|
|
184
|
+
carries no aria-checked, and what marks the current one is aria-current,
|
|
185
|
+
the attribute a reader already knows from navigation. -->
|
|
138
186
|
<div
|
|
139
187
|
bind:this={rootEl}
|
|
140
|
-
role=
|
|
188
|
+
role={linked ? 'group' : 'radiogroup'}
|
|
141
189
|
aria-label={label}
|
|
190
|
+
onfocusout={linked ? onGroupFocusOut : undefined}
|
|
142
191
|
class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
|
|
143
192
|
size
|
|
144
193
|
].row} {disabled ? 'opacity-50' : ''}"
|
|
@@ -146,28 +195,13 @@
|
|
|
146
195
|
{#each options as option, i (option.value)}
|
|
147
196
|
{@const selected = option.value === value}
|
|
148
197
|
{@const Icon = option.icon}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
-->
|
|
157
|
-
<button
|
|
158
|
-
type="button"
|
|
159
|
-
role="radio"
|
|
160
|
-
aria-checked={selected}
|
|
161
|
-
tabindex={i === tabStop ? 0 : -1}
|
|
162
|
-
{disabled}
|
|
163
|
-
onclick={() => choose(i)}
|
|
164
|
-
onkeydown={(e) => onSegmentKeydown(e, i)}
|
|
165
|
-
class="inline-flex items-center justify-center rounded-md outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed {SIZES[
|
|
166
|
-
size
|
|
167
|
-
].segment} {selected
|
|
168
|
-
? 'bg-surface text-fg shadow-sm'
|
|
169
|
-
: 'text-muted hover:text-fg active:bg-surface active:text-fg'}"
|
|
170
|
-
>
|
|
198
|
+
{@const segment =
|
|
199
|
+
'inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ' +
|
|
200
|
+
SIZES[size].segment +
|
|
201
|
+
(selected
|
|
202
|
+
? ' bg-surface text-fg shadow-sm'
|
|
203
|
+
: ' text-muted hover:text-fg active:bg-surface active:text-fg')}
|
|
204
|
+
{#snippet content()}
|
|
171
205
|
{#if Icon}
|
|
172
206
|
<Icon size={SIZES[size].icon} class="shrink-0" />
|
|
173
207
|
{/if}
|
|
@@ -183,7 +217,47 @@
|
|
|
183
217
|
>{option.label}</span
|
|
184
218
|
>
|
|
185
219
|
</span>
|
|
186
|
-
|
|
220
|
+
{/snippet}
|
|
221
|
+
{#if linked}
|
|
222
|
+
<!--
|
|
223
|
+
A plain anchor with no click handler, so the choice is a navigation
|
|
224
|
+
the browser performs on its own. A disabled row drops the hrefs: an
|
|
225
|
+
anchor with no href is not a link, and aria-disabled says why.
|
|
226
|
+
-->
|
|
227
|
+
<a
|
|
228
|
+
data-segment
|
|
229
|
+
href={disabled ? undefined : safeHref(option.href)}
|
|
230
|
+
aria-current={selected ? 'page' : undefined}
|
|
231
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
232
|
+
tabindex={disabled ? -1 : i === tabStop ? 0 : -1}
|
|
233
|
+
onkeydown={(e) => onSegmentKeydown(e, i)}
|
|
234
|
+
class="{segment} {disabled ? 'cursor-not-allowed' : ''}"
|
|
235
|
+
>
|
|
236
|
+
{@render content()}
|
|
237
|
+
</a>
|
|
238
|
+
{:else}
|
|
239
|
+
<!--
|
|
240
|
+
Each segment is a radio, and aria-checked is what a screen reader
|
|
241
|
+
reads. The theme picker this replaces was a row of plain buttons whose
|
|
242
|
+
choice was carried by a background colour alone, so a screen reader
|
|
243
|
+
user was told nothing and a colour-blind user saw nothing. The
|
|
244
|
+
selected segment also sits at a heavier weight, so the state survives
|
|
245
|
+
a palette a reader cannot separate.
|
|
246
|
+
-->
|
|
247
|
+
<button
|
|
248
|
+
data-segment
|
|
249
|
+
type="button"
|
|
250
|
+
role="radio"
|
|
251
|
+
aria-checked={selected}
|
|
252
|
+
tabindex={i === tabStop ? 0 : -1}
|
|
253
|
+
{disabled}
|
|
254
|
+
onclick={() => choose(i)}
|
|
255
|
+
onkeydown={(e) => onSegmentKeydown(e, i)}
|
|
256
|
+
class={segment}
|
|
257
|
+
>
|
|
258
|
+
{@render content()}
|
|
259
|
+
</button>
|
|
260
|
+
{/if}
|
|
187
261
|
{/each}
|
|
188
262
|
</div>
|
|
189
263
|
|
|
@@ -10,6 +10,14 @@ export interface SegmentOption<T extends string = string> {
|
|
|
10
10
|
size?: number;
|
|
11
11
|
class?: string;
|
|
12
12
|
}>;
|
|
13
|
+
/**
|
|
14
|
+
* Renders the segment as a link to this URL instead of a radio. Every
|
|
15
|
+
* option carries one or none does; the modes cannot be mixed in one
|
|
16
|
+
* control. The chosen segment is still the one whose value matches
|
|
17
|
+
* `value`, and it carries aria-current="page", so a page can keep its
|
|
18
|
+
* choice in the URL and the control works before any script has loaded.
|
|
19
|
+
*/
|
|
20
|
+
href?: string;
|
|
13
21
|
}
|
|
14
22
|
declare function $$render<T extends string>(): {
|
|
15
23
|
props: {
|
|
@@ -29,6 +37,7 @@ declare function $$render<T extends string>(): {
|
|
|
29
37
|
size?: "sm" | "md";
|
|
30
38
|
disabled?: boolean;
|
|
31
39
|
class?: string;
|
|
40
|
+
/** Value mode only. A link segment reports nothing; the page it opens does. */
|
|
32
41
|
onchange?: (value: T) => void;
|
|
33
42
|
};
|
|
34
43
|
exports: {};
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
panelOption,
|
|
85
85
|
placePanel,
|
|
86
86
|
} from '../internal/panel.js';
|
|
87
|
+
import * as motion from '../motion.js';
|
|
87
88
|
|
|
88
89
|
interface Props {
|
|
89
90
|
/** The selected value. Bindable, so a listbox pick reaches the caller without a callback. */
|
|
@@ -283,7 +284,7 @@
|
|
|
283
284
|
height="12"
|
|
284
285
|
viewBox="0 0 12 12"
|
|
285
286
|
fill="none"
|
|
286
|
-
class="transition-transform duration-
|
|
287
|
+
class="transition-transform duration-base {open ? 'rotate-180' : ''}"
|
|
287
288
|
>
|
|
288
289
|
<path
|
|
289
290
|
d="M2 4l4 4 4-4"
|
|
@@ -408,7 +409,7 @@
|
|
|
408
409
|
<input type="hidden" {name} {disabled} value={value ?? ''} />
|
|
409
410
|
|
|
410
411
|
{#if box.open}
|
|
411
|
-
<div use:placePanel class="{PANEL_SURFACE} w-full">
|
|
412
|
+
<div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
|
|
412
413
|
{#if searchable}
|
|
413
414
|
<div class="border-b border-line p-2">
|
|
414
415
|
<!--
|
|
@@ -432,7 +433,7 @@
|
|
|
432
433
|
aria-label={label ? `Search ${label}` : 'Search options'}
|
|
433
434
|
{...box.triggerAttrs}
|
|
434
435
|
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
435
|
-
text-sm text-fg outline-none transition-colors
|
|
436
|
+
text-sm text-fg outline-none transition-colors
|
|
436
437
|
placeholder:text-faint focus:border-brand"
|
|
437
438
|
/>
|
|
438
439
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { activeTrail, type NavNode, type NavTree } from '../internal/nav-tree.js';
|
|
3
|
+
import { TOUCH_GROW } from '../internal/touch.js';
|
|
3
4
|
import { createNavExpansion } from '../internal/nav-expansion.svelte.js';
|
|
4
5
|
import type { AccentTone } from '../internal/tone.js';
|
|
5
6
|
|
|
@@ -65,8 +66,8 @@
|
|
|
65
66
|
* stray line or as nothing at all.
|
|
66
67
|
*/
|
|
67
68
|
const ROW =
|
|
68
|
-
|
|
69
|
-
'transition-colors
|
|
69
|
+
`${TOUCH_GROW} flex w-full items-center gap-2.5 rounded-lg border-s-2 py-2 pe-2 text-sm text-start ` +
|
|
70
|
+
'transition-colors outline-none ' +
|
|
70
71
|
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
71
72
|
|
|
72
73
|
/**
|
|
@@ -99,8 +100,8 @@
|
|
|
99
100
|
* it wrong half the time.
|
|
100
101
|
*/
|
|
101
102
|
const DISCLOSURE =
|
|
102
|
-
|
|
103
|
-
'transition-colors
|
|
103
|
+
`${TOUCH_GROW} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ` +
|
|
104
|
+
'transition-colors outline-none hover:bg-surface-2 hover:text-fg ' +
|
|
104
105
|
'active:bg-line active:text-fg ' +
|
|
105
106
|
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
106
107
|
|
|
@@ -219,11 +220,13 @@
|
|
|
219
220
|
|
|
220
221
|
{#if branch}
|
|
221
222
|
<!--
|
|
222
|
-
Rendered whether or not it is open, and
|
|
223
|
-
|
|
224
|
-
that
|
|
225
|
-
|
|
226
|
-
will reveal.
|
|
223
|
+
Rendered whether or not it is open, and shut by collapsing its grid row
|
|
224
|
+
to nothing, the same move AccordionItem makes. aria-controls has to name
|
|
225
|
+
an element that exists; pointing it at markup that appears only once the
|
|
226
|
+
group is open leaves the reference dangling in the one state where a
|
|
227
|
+
reader needs it to tell them what the button will reveal. inert keeps a
|
|
228
|
+
shut group out of the tab order and the accessibility tree, which is
|
|
229
|
+
what the display utility used to do.
|
|
227
230
|
|
|
228
231
|
It keeps the implicit list role. role="group" here overrode it, and a
|
|
229
232
|
listitem whose parent is not a list is an ARIA context error, so every
|
|
@@ -231,14 +234,20 @@
|
|
|
231
234
|
semantics belong to a tree, where the parent is a treeitem; this is a
|
|
232
235
|
nav of plain links and the disclosure already says what it controls.
|
|
233
236
|
-->
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
class="
|
|
237
|
+
{@const shown = open && !node.disabled}
|
|
238
|
+
<div
|
|
239
|
+
class="grid transition-[grid-template-rows] duration-base {shown
|
|
240
|
+
? 'grid-rows-[1fr]'
|
|
241
|
+
: 'grid-rows-[0fr]'}"
|
|
237
242
|
>
|
|
238
|
-
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
<div class="overflow-hidden">
|
|
244
|
+
<ul id={listId(node.id)} inert={!shown} class="mt-0.5 flex flex-col gap-0.5">
|
|
245
|
+
{#each children as child (child.id)}
|
|
246
|
+
{@render row(child, depth + 1)}
|
|
247
|
+
{/each}
|
|
248
|
+
</ul>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
242
251
|
{/if}
|
|
243
252
|
</li>
|
|
244
253
|
{/snippet}
|
|
@@ -272,7 +281,7 @@
|
|
|
272
281
|
stroke-linecap="round"
|
|
273
282
|
stroke-linejoin="round"
|
|
274
283
|
aria-hidden="true"
|
|
275
|
-
class="shrink-0 transition-transform duration-
|
|
284
|
+
class="shrink-0 transition-transform duration-base {open ? 'rotate-90' : 'rtl:rotate-180'}"
|
|
276
285
|
>
|
|
277
286
|
<path d="M9 18l6-6-6-6" />
|
|
278
287
|
</svg>
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
? ''
|
|
30
30
|
: done || active
|
|
31
31
|
? 'bg-brand'
|
|
32
|
-
: 'bg-line'} h-px transition-colors
|
|
32
|
+
: 'bg-line'} h-px transition-colors"
|
|
33
33
|
></div>
|
|
34
34
|
|
|
35
35
|
<!-- Step circle -->
|
|
36
36
|
<div
|
|
37
37
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2
|
|
38
|
-
font-bold text-sm transition-
|
|
38
|
+
font-bold text-sm transition-colors
|
|
39
39
|
{done
|
|
40
40
|
? 'border-brand bg-brand text-ink'
|
|
41
41
|
: active
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
? ''
|
|
63
63
|
: done
|
|
64
64
|
? 'bg-brand'
|
|
65
|
-
: 'bg-line'} h-px transition-colors
|
|
65
|
+
: 'bg-line'} h-px transition-colors"
|
|
66
66
|
></div>
|
|
67
67
|
</div>
|
|
68
68
|
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
? 'text-fg'
|
|
73
73
|
: done
|
|
74
74
|
? 'text-muted'
|
|
75
|
-
: 'text-faint'} transition-colors
|
|
75
|
+
: 'text-faint'} transition-colors"
|
|
76
76
|
>
|
|
77
77
|
{step.label}
|
|
78
78
|
</p>
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
<div class="flex flex-col items-center">
|
|
96
96
|
<div
|
|
97
97
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2
|
|
98
|
-
font-bold text-sm transition-
|
|
98
|
+
font-bold text-sm transition-colors
|
|
99
99
|
{done
|
|
100
100
|
? 'border-brand bg-brand text-ink'
|
|
101
101
|
: active
|
|
@@ -117,9 +117,7 @@
|
|
|
117
117
|
</div>
|
|
118
118
|
{#if i < steps.length - 1}
|
|
119
119
|
<div
|
|
120
|
-
class="my-1 w-px flex-1 {done
|
|
121
|
-
? 'bg-brand/40'
|
|
122
|
-
: 'bg-line'} transition-colors duration-150"
|
|
120
|
+
class="my-1 w-px flex-1 {done ? 'bg-brand/40' : 'bg-line'} transition-colors"
|
|
123
121
|
></div>
|
|
124
122
|
{/if}
|
|
125
123
|
</div>
|
|
@@ -129,7 +127,7 @@
|
|
|
129
127
|
? 'text-fg'
|
|
130
128
|
: done
|
|
131
129
|
? 'text-muted'
|
|
132
|
-
: 'text-faint'} transition-colors
|
|
130
|
+
: 'text-faint'} transition-colors"
|
|
133
131
|
>
|
|
134
132
|
{step.label}
|
|
135
133
|
</p>
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
<script lang="ts">
|
|
13
13
|
import type { Snippet } from 'svelte';
|
|
14
|
+
import {
|
|
15
|
+
SCROLL_EDGE,
|
|
16
|
+
SCROLL_EDGE_END,
|
|
17
|
+
SCROLL_EDGE_START,
|
|
18
|
+
scrollEdges,
|
|
19
|
+
} from '../internal/scroll-edges.js';
|
|
14
20
|
|
|
15
21
|
interface Props {
|
|
16
22
|
striped?: boolean;
|
|
@@ -70,7 +76,7 @@
|
|
|
70
76
|
'[&_tbody_tr:hover>td:first-child]:shadow-[inset_1px_1px_var(--color-brand),inset_1px_-1px_var(--color-brand)]',
|
|
71
77
|
'[&_tbody_tr:hover>td:last-child]:shadow-[inset_-1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
|
|
72
78
|
'[&_tbody_tr:hover>td:first-child:last-child]:shadow-[inset_1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
|
|
73
|
-
'[&_tbody_tr]:transition-colors
|
|
79
|
+
'[&_tbody_tr]:transition-colors',
|
|
74
80
|
].join(' ');
|
|
75
81
|
|
|
76
82
|
let {
|
|
@@ -121,19 +127,9 @@
|
|
|
121
127
|
function measure() {
|
|
122
128
|
const el = scroller;
|
|
123
129
|
if (!el) return;
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (slack <= 1) {
|
|
128
|
-
moreBefore = false;
|
|
129
|
-
moreAfter = false;
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
// A right-to-left box reports the offset as a negative number, and the two
|
|
133
|
-
// edges are the same two edges either way round.
|
|
134
|
-
const travelled = Math.abs(el.scrollLeft);
|
|
135
|
-
moreBefore = travelled > 1;
|
|
136
|
-
moreAfter = travelled < slack - 1;
|
|
130
|
+
const edges = scrollEdges(el);
|
|
131
|
+
moreBefore = edges.before;
|
|
132
|
+
moreAfter = edges.after;
|
|
137
133
|
}
|
|
138
134
|
|
|
139
135
|
$effect(() => {
|
|
@@ -151,14 +147,6 @@
|
|
|
151
147
|
return () => observer.disconnect();
|
|
152
148
|
});
|
|
153
149
|
|
|
154
|
-
/*
|
|
155
|
-
* A dark fade rather than a palette colour. The box has no background of its
|
|
156
|
-
* own and sits on the page in one place and inside a card in another, so no
|
|
157
|
-
* surface token is the colour to fade to. The scrims use black at an alpha
|
|
158
|
-
* for the same reason and it reads in both themes.
|
|
159
|
-
*/
|
|
160
|
-
const EDGE = 'pointer-events-none absolute inset-y-px w-6';
|
|
161
|
-
|
|
162
150
|
/*
|
|
163
151
|
* The table-wide treatment, applied only to cells that did not ask for their
|
|
164
152
|
* own.
|
|
@@ -185,6 +173,28 @@
|
|
|
185
173
|
'[&_tbody_td[data-cell=truncate]]:max-w-[var(--cell-truncate)] ' +
|
|
186
174
|
'[&_tbody_td[data-cell=truncate]]:truncate';
|
|
187
175
|
|
|
176
|
+
/*
|
|
177
|
+
* Column alignment. Head cells align start: the table already said
|
|
178
|
+
* `text-start`, and the browser's own `th { text-align: center }` beat the
|
|
179
|
+
* inherited value, so every heading floated over a left-aligned column
|
|
180
|
+
* until the page wrote `text-left` on each one. A numeric column is the one
|
|
181
|
+
* kind that reads better ranged right, and its digits line up only in a
|
|
182
|
+
* tabular figure, so a caller marks its cells `data-col="numeric"` and the
|
|
183
|
+
* heading and the cells take both together.
|
|
184
|
+
*/
|
|
185
|
+
const COLUMN_ALIGN =
|
|
186
|
+
'[&_thead_th]:text-start ' +
|
|
187
|
+
'[&_th[data-col=numeric]]:text-end [&_td[data-col=numeric]]:text-end ' +
|
|
188
|
+
'[&_td[data-col=numeric]]:tabular-nums';
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
* A sort control in a heading is the caller's button, and the head row is
|
|
192
|
+
* 40px. The hit box every small kit control grows under a finger is given
|
|
193
|
+
* to it here, so a table sorted from its headings needs nothing from the
|
|
194
|
+
* page to meet the finger.
|
|
195
|
+
*/
|
|
196
|
+
const HEAD_CONTROLS = '[&_thead_th_button]:relative [&_thead_th_button]:hit-area';
|
|
197
|
+
|
|
188
198
|
/** Marks a title this component put there, so it can take it back again. */
|
|
189
199
|
const OWNED = 'data-cell-title';
|
|
190
200
|
|
|
@@ -230,7 +240,10 @@
|
|
|
230
240
|
<!-- overflow-hidden on the frame, not only on the scroller: the hovered row's
|
|
231
241
|
ring is square and the frame is not, and the last row's corners drew past
|
|
232
242
|
the frame's rounded ones. -->
|
|
233
|
-
<div
|
|
243
|
+
<div
|
|
244
|
+
data-testid="table-frame"
|
|
245
|
+
class="relative w-full overflow-hidden rounded-xl border border-line {cls}"
|
|
246
|
+
>
|
|
234
247
|
<!--
|
|
235
248
|
data-print: the box exists to clip. On paper there is no viewport to clip
|
|
236
249
|
to, and every column past the edge would simply not be printed.
|
|
@@ -265,7 +278,7 @@
|
|
|
265
278
|
style="--cell-truncate: {truncateAt}"
|
|
266
279
|
class="w-full border-separate border-spacing-0 text-sm text-start
|
|
267
280
|
{fixed ? 'table-fixed' : ''}
|
|
268
|
-
{CELL_FIT[cell]} {CELL_NAMED}
|
|
281
|
+
{CELL_FIT[cell]} {CELL_NAMED} {COLUMN_ALIGN} {HEAD_CONTROLS}
|
|
269
282
|
[&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
|
|
270
283
|
[&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
|
|
271
284
|
[&_thead_th]:font-medium [&_thead_th]:text-faint
|
|
@@ -286,16 +299,13 @@
|
|
|
286
299
|
the phone will not draw, it says nothing a screen reader has not already
|
|
287
300
|
been told by the region, and a child of the box would scroll away with the
|
|
288
301
|
content it is describing.
|
|
289
|
-
|
|
290
|
-
The gradient direction is logical: in a right-to-left locale the start edge
|
|
291
|
-
is the right one and the fade has to run the other way with it.
|
|
292
302
|
-->
|
|
293
303
|
{#if moreBefore}
|
|
294
304
|
<div
|
|
295
305
|
aria-hidden="true"
|
|
296
306
|
data-print="hide"
|
|
297
307
|
data-testid="table-more-before"
|
|
298
|
-
class="{
|
|
308
|
+
class="{SCROLL_EDGE} {SCROLL_EDGE_START} rounded-s-xl"
|
|
299
309
|
></div>
|
|
300
310
|
{/if}
|
|
301
311
|
{#if moreAfter}
|
|
@@ -303,7 +313,7 @@
|
|
|
303
313
|
aria-hidden="true"
|
|
304
314
|
data-print="hide"
|
|
305
315
|
data-testid="table-more-after"
|
|
306
|
-
class="{
|
|
316
|
+
class="{SCROLL_EDGE} {SCROLL_EDGE_END} rounded-e-xl"
|
|
307
317
|
></div>
|
|
308
318
|
{/if}
|
|
309
319
|
</div>
|