@lyeve-labs/ui-kit 0.18.0 → 0.19.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 +11 -4
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/AppShell.svelte +3 -3
- package/dist/components/Autocomplete.svelte +4 -4
- package/dist/components/AvatarGroup.svelte +2 -2
- package/dist/components/Breadcrumb.svelte +1 -1
- package/dist/components/Button.svelte +34 -9
- package/dist/components/ButtonGroup.svelte +3 -3
- package/dist/components/Card.svelte +1 -1
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CheckboxGroup.svelte +1 -1
- package/dist/components/Collapsible.svelte +3 -3
- package/dist/components/CopyButton.svelte +1 -1
- package/dist/components/DatePicker.svelte +2 -2
- package/dist/components/DateTimePicker.svelte +1 -1
- package/dist/components/Drawer.svelte +1 -1
- package/dist/components/Dropdown.svelte +1 -1
- package/dist/components/Field.svelte +1 -1
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +1 -1
- package/dist/components/MultiSelect.svelte +4 -4
- package/dist/components/NumberInput.svelte +3 -3
- package/dist/components/Pagination.svelte +164 -65
- package/dist/components/Pagination.svelte.d.ts +39 -2
- package/dist/components/PasswordInput.svelte +4 -4
- package/dist/components/Radio.svelte +1 -1
- package/dist/components/RadioGroup.svelte +1 -1
- package/dist/components/SearchInput.svelte +4 -4
- package/dist/components/SegmentedControl.svelte +3 -1
- package/dist/components/Select.svelte +6 -6
- package/dist/components/SidebarNav.svelte +20 -5
- package/dist/components/Table.svelte +114 -1
- package/dist/components/Table.svelte.d.ts +37 -0
- package/dist/components/Tabs.svelte +1 -1
- package/dist/components/Tag.svelte +1 -1
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/ThemeToggle.svelte +1 -1
- package/dist/components/TimePicker.svelte +2 -2
- package/dist/components/Toaster.svelte +2 -2
- package/dist/components/Toggle.svelte +10 -4
- package/dist/components/TreeView.svelte +4 -4
- package/dist/components/dialog/Dialog.svelte +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/internal/href.d.ts +13 -0
- package/dist/internal/href.js +27 -0
- package/dist/internal/panel.js +1 -1
- package/dist/styles/theme.css +53 -0
- package/package.json +3 -4
- package/src/lib/styles/theme.css +53 -0
|
@@ -1,24 +1,73 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { safeHref } from '../internal/href.js';
|
|
3
|
+
|
|
4
|
+
interface Base {
|
|
5
|
+
page: number;
|
|
6
|
+
/**
|
|
7
|
+
* Collection size, when the endpoint states one.
|
|
8
|
+
*
|
|
9
|
+
* Leave it out for a list whose endpoint reports no count and say `hasNext`
|
|
10
|
+
* instead. Absent is not zero: a caller who does state a total, even a
|
|
11
|
+
* nonsense one, gets the counted pager with that total clamped.
|
|
12
|
+
*/
|
|
13
|
+
total?: number;
|
|
14
|
+
perPage?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a page after this one exists.
|
|
17
|
+
*
|
|
18
|
+
* Read only when `total` is absent, because a counted list already knows
|
|
19
|
+
* where it ends. It is the answer an endpoint can give without counting the
|
|
20
|
+
* collection: ask for one row more than the page holds and see if it comes
|
|
21
|
+
* back.
|
|
22
|
+
*/
|
|
23
|
+
hasNext?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How the pager moves, and why it takes both spellings.
|
|
29
|
+
*
|
|
30
|
+
* `onchange` cannot run before the page hydrates, so a pager that has only a
|
|
31
|
+
* callback is inert on the first paint and an early click lands on nothing.
|
|
32
|
+
* That is not a rare state: it is every server-rendered document, for as long
|
|
33
|
+
* as the bundle takes to arrive. A caller that has to page from that document
|
|
34
|
+
* supplies `href` instead and gets links, which the browser follows with no
|
|
35
|
+
* script at all.
|
|
36
|
+
*
|
|
37
|
+
* Exclusive rather than both together: an anchor whose click also ran the
|
|
38
|
+
* callback would page twice, once through the handler and again through the
|
|
39
|
+
* navigation it did not cancel.
|
|
40
|
+
*/
|
|
41
|
+
type Nav =
|
|
42
|
+
| { onchange: (page: number) => void; href?: undefined }
|
|
43
|
+
| { href: (page: number) => string; onchange?: undefined };
|
|
44
|
+
|
|
2
45
|
let {
|
|
3
46
|
page,
|
|
4
|
-
total,
|
|
47
|
+
total = undefined,
|
|
5
48
|
perPage = 20,
|
|
49
|
+
hasNext = false,
|
|
6
50
|
class: cls = '',
|
|
7
51
|
onchange,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
total: number;
|
|
11
|
-
perPage?: number;
|
|
12
|
-
class?: string;
|
|
13
|
-
onchange: (page: number) => void;
|
|
14
|
-
} = $props();
|
|
52
|
+
href,
|
|
53
|
+
}: Base & Nav = $props();
|
|
15
54
|
|
|
16
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A stated total is what makes the numbered pages knowable. Without one the
|
|
57
|
+
* pager can offer the step either side of where it stands and nothing more,
|
|
58
|
+
* because the last page is not a number anybody here has.
|
|
59
|
+
*/
|
|
60
|
+
let counted = $derived(total !== undefined);
|
|
61
|
+
let safeTotal = $derived(typeof total === 'number' && isFinite(total) && total >= 0 ? total : 0);
|
|
17
62
|
let safePage = $derived(isFinite(page) && page >= 1 ? page : 1);
|
|
18
63
|
let totalPages = $derived(Math.max(1, Math.ceil(safeTotal / perPage)));
|
|
19
64
|
let from = $derived(Math.min((safePage - 1) * perPage + 1, safeTotal));
|
|
20
65
|
let to = $derived(Math.min(safePage * perPage, safeTotal));
|
|
21
66
|
|
|
67
|
+
let canPrev = $derived(safePage > 1);
|
|
68
|
+
let canNext = $derived(counted ? safePage < totalPages : hasNext);
|
|
69
|
+
let hasControls = $derived(counted ? totalPages > 1 : canPrev || canNext);
|
|
70
|
+
|
|
22
71
|
/**
|
|
23
72
|
* The elided run is the sentinel string 'gap', never the horizontal-ellipsis
|
|
24
73
|
* character.
|
|
@@ -51,46 +100,112 @@
|
|
|
51
100
|
return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
|
|
52
101
|
}
|
|
53
102
|
|
|
54
|
-
let nums = $derived(pageNumbers(safePage, totalPages));
|
|
103
|
+
let nums = $derived(counted ? pageNumbers(safePage, totalPages) : []);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The count, when there is one to state.
|
|
107
|
+
*
|
|
108
|
+
* An uncounted list says which page it is on and nothing else. It cannot say
|
|
109
|
+
* "1 to 20 of 400" without the total, and it cannot say "1 to 20" either: the
|
|
110
|
+
* last page is short and this component is never told how short.
|
|
111
|
+
*/
|
|
112
|
+
let summary = $derived(
|
|
113
|
+
counted
|
|
114
|
+
? safeTotal === 0
|
|
115
|
+
? 'No results'
|
|
116
|
+
: `${from} to ${to} of ${safeTotal}`
|
|
117
|
+
: `Page ${safePage}`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** The target of a control, sanitized, or nothing when the step is unavailable. */
|
|
121
|
+
function linkTo(target: number, enabled: boolean): string | undefined {
|
|
122
|
+
return href && enabled ? safeHref(href(target)) : undefined;
|
|
123
|
+
}
|
|
55
124
|
|
|
56
125
|
const btnBase =
|
|
57
126
|
'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A page control that is not the page you are on, in every state a pointer
|
|
130
|
+
* can put it in.
|
|
131
|
+
*
|
|
132
|
+
* Held as well as hovered, because `hover:` never matches on a touch screen
|
|
133
|
+
* and paging is a thumb gesture: without the pressed step a tap on a phone
|
|
134
|
+
* changed the page with nothing on the control to say it had been hit.
|
|
135
|
+
*
|
|
136
|
+
* Stated once rather than spelled out on the previous button, the next button
|
|
137
|
+
* and every numbered one. It was written three times, and a state added to
|
|
138
|
+
* one copy is a state missing from two.
|
|
139
|
+
*/
|
|
140
|
+
const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
|
|
141
|
+
|
|
142
|
+
const btnCurrent = 'bg-brand text-ink';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A link with nowhere to go.
|
|
146
|
+
*
|
|
147
|
+
* An anchor has no `disabled`, and `disabled:` never matches one, so the two
|
|
148
|
+
* states have to be painted rather than declared. Dropping the href is what
|
|
149
|
+
* takes it out of the tab order and stops activation; the class is only the
|
|
150
|
+
* part a reader can see, at the same 30% the disabled buttons use.
|
|
151
|
+
*/
|
|
152
|
+
const linkInert = 'pointer-events-none opacity-30';
|
|
58
153
|
</script>
|
|
59
154
|
|
|
155
|
+
{#snippet arrow(d: string)}
|
|
156
|
+
<svg
|
|
157
|
+
width="14"
|
|
158
|
+
height="14"
|
|
159
|
+
viewBox="0 0 24 24"
|
|
160
|
+
fill="none"
|
|
161
|
+
stroke="currentColor"
|
|
162
|
+
stroke-width="2"
|
|
163
|
+
stroke-linecap="round"
|
|
164
|
+
stroke-linejoin="round"
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
class="rtl:rotate-180"
|
|
167
|
+
>
|
|
168
|
+
<path {d} />
|
|
169
|
+
</svg>
|
|
170
|
+
{/snippet}
|
|
171
|
+
|
|
172
|
+
<!-- Previous and next, as a link when the caller builds hrefs and as a button
|
|
173
|
+
when it hands over a callback. The two branches carry the same classes and
|
|
174
|
+
the same label, so the pager reads and sounds the same either way. -->
|
|
175
|
+
{#snippet step(target: number, label: string, enabled: boolean, d: string)}
|
|
176
|
+
{#if href}
|
|
177
|
+
<a
|
|
178
|
+
href={linkTo(target, enabled)}
|
|
179
|
+
aria-label={label}
|
|
180
|
+
aria-disabled={enabled ? undefined : 'true'}
|
|
181
|
+
class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
|
|
182
|
+
>
|
|
183
|
+
{@render arrow(d)}
|
|
184
|
+
</a>
|
|
185
|
+
{:else}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
disabled={!enabled}
|
|
189
|
+
onclick={() => onchange?.(target)}
|
|
190
|
+
aria-label={label}
|
|
191
|
+
class="{btnBase} {btnRest} disabled:cursor-not-allowed disabled:opacity-30"
|
|
192
|
+
>
|
|
193
|
+
{@render arrow(d)}
|
|
194
|
+
</button>
|
|
195
|
+
{/if}
|
|
196
|
+
{/snippet}
|
|
197
|
+
|
|
60
198
|
<!-- The summary renders whenever there is a count to state, and the page
|
|
61
199
|
buttons only when there is more than one page. The whole component used to
|
|
62
200
|
be behind `totalPages > 1`, and an empty list has one page, so the 'No
|
|
63
201
|
results' line below could never appear and a single page of results showed
|
|
64
202
|
no count at all. -->
|
|
65
203
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
|
|
66
|
-
<span class="text-xs text-faint
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div class="flex flex-wrap items-center gap-0.5 ml-auto">
|
|
72
|
-
<button
|
|
73
|
-
type="button"
|
|
74
|
-
disabled={safePage <= 1}
|
|
75
|
-
onclick={() => onchange(safePage - 1)}
|
|
76
|
-
aria-label="Previous page"
|
|
77
|
-
class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
|
|
78
|
-
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
79
|
-
>
|
|
80
|
-
<svg
|
|
81
|
-
width="14"
|
|
82
|
-
height="14"
|
|
83
|
-
viewBox="0 0 24 24"
|
|
84
|
-
fill="none"
|
|
85
|
-
stroke="currentColor"
|
|
86
|
-
stroke-width="2"
|
|
87
|
-
stroke-linecap="round"
|
|
88
|
-
stroke-linejoin="round"
|
|
89
|
-
aria-hidden="true"
|
|
90
|
-
>
|
|
91
|
-
<path d="M15 18l-6-6 6-6" />
|
|
92
|
-
</svg>
|
|
93
|
-
</button>
|
|
204
|
+
<span class="shrink-0 text-xs text-faint">{summary}</span>
|
|
205
|
+
|
|
206
|
+
{#if hasControls}
|
|
207
|
+
<div class="ms-auto flex flex-wrap items-center gap-0.5">
|
|
208
|
+
{@render step(safePage - 1, 'Previous page', canPrev, 'M15 18l-6-6 6-6')}
|
|
94
209
|
|
|
95
210
|
{#each nums as n, i (slotKey(n, i))}
|
|
96
211
|
{#if n === 'gap'}
|
|
@@ -111,43 +226,27 @@
|
|
|
111
226
|
<path d="M5 12h.01M12 12h.01M19 12h.01" />
|
|
112
227
|
</svg>
|
|
113
228
|
</span>
|
|
229
|
+
{:else if href}
|
|
230
|
+
<a
|
|
231
|
+
href={linkTo(n, true)}
|
|
232
|
+
aria-current={safePage === n ? 'page' : undefined}
|
|
233
|
+
class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
|
|
234
|
+
>
|
|
235
|
+
{n}
|
|
236
|
+
</a>
|
|
114
237
|
{:else}
|
|
115
238
|
<button
|
|
116
239
|
type="button"
|
|
117
|
-
onclick={() => onchange(n as number)}
|
|
240
|
+
onclick={() => onchange?.(n as number)}
|
|
118
241
|
aria-current={safePage === n ? 'page' : undefined}
|
|
119
|
-
class="{btnBase}
|
|
120
|
-
{safePage === n
|
|
121
|
-
? 'bg-brand text-ink'
|
|
122
|
-
: 'text-muted hover:text-fg hover:bg-surface-2'}"
|
|
242
|
+
class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
|
|
123
243
|
>
|
|
124
244
|
{n}
|
|
125
245
|
</button>
|
|
126
246
|
{/if}
|
|
127
247
|
{/each}
|
|
128
248
|
|
|
129
|
-
|
|
130
|
-
type="button"
|
|
131
|
-
disabled={safePage >= totalPages}
|
|
132
|
-
onclick={() => onchange(safePage + 1)}
|
|
133
|
-
aria-label="Next page"
|
|
134
|
-
class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
|
|
135
|
-
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
136
|
-
>
|
|
137
|
-
<svg
|
|
138
|
-
width="14"
|
|
139
|
-
height="14"
|
|
140
|
-
viewBox="0 0 24 24"
|
|
141
|
-
fill="none"
|
|
142
|
-
stroke="currentColor"
|
|
143
|
-
stroke-width="2"
|
|
144
|
-
stroke-linecap="round"
|
|
145
|
-
stroke-linejoin="round"
|
|
146
|
-
aria-hidden="true"
|
|
147
|
-
>
|
|
148
|
-
<path d="M9 18l6-6-6-6" />
|
|
149
|
-
</svg>
|
|
150
|
-
</button>
|
|
249
|
+
{@render step(safePage + 1, 'Next page', canNext, 'M9 18l6-6-6-6')}
|
|
151
250
|
</div>
|
|
152
251
|
{/if}
|
|
153
252
|
</div>
|
|
@@ -1,10 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
interface Base {
|
|
2
2
|
page: number;
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Collection size, when the endpoint states one.
|
|
5
|
+
*
|
|
6
|
+
* Leave it out for a list whose endpoint reports no count and say `hasNext`
|
|
7
|
+
* instead. Absent is not zero: a caller who does state a total, even a
|
|
8
|
+
* nonsense one, gets the counted pager with that total clamped.
|
|
9
|
+
*/
|
|
10
|
+
total?: number;
|
|
4
11
|
perPage?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a page after this one exists.
|
|
14
|
+
*
|
|
15
|
+
* Read only when `total` is absent, because a counted list already knows
|
|
16
|
+
* where it ends. It is the answer an endpoint can give without counting the
|
|
17
|
+
* collection: ask for one row more than the page holds and see if it comes
|
|
18
|
+
* back.
|
|
19
|
+
*/
|
|
20
|
+
hasNext?: boolean;
|
|
5
21
|
class?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* How the pager moves, and why it takes both spellings.
|
|
25
|
+
*
|
|
26
|
+
* `onchange` cannot run before the page hydrates, so a pager that has only a
|
|
27
|
+
* callback is inert on the first paint and an early click lands on nothing.
|
|
28
|
+
* That is not a rare state: it is every server-rendered document, for as long
|
|
29
|
+
* as the bundle takes to arrive. A caller that has to page from that document
|
|
30
|
+
* supplies `href` instead and gets links, which the browser follows with no
|
|
31
|
+
* script at all.
|
|
32
|
+
*
|
|
33
|
+
* Exclusive rather than both together: an anchor whose click also ran the
|
|
34
|
+
* callback would page twice, once through the handler and again through the
|
|
35
|
+
* navigation it did not cancel.
|
|
36
|
+
*/
|
|
37
|
+
type Nav = {
|
|
6
38
|
onchange: (page: number) => void;
|
|
39
|
+
href?: undefined;
|
|
40
|
+
} | {
|
|
41
|
+
href: (page: number) => string;
|
|
42
|
+
onchange?: undefined;
|
|
7
43
|
};
|
|
44
|
+
type $$ComponentProps = Base & Nav;
|
|
8
45
|
declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
46
|
type Pagination = ReturnType<typeof Pagination>;
|
|
10
47
|
export default Pagination;
|
|
@@ -85,12 +85,12 @@
|
|
|
85
85
|
<div class="{FIELD_WRAP} {klass}">
|
|
86
86
|
{#if label}
|
|
87
87
|
<label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
88
|
-
{label}{#if required}<span class="
|
|
88
|
+
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
89
89
|
</label>
|
|
90
90
|
{/if}
|
|
91
91
|
|
|
92
92
|
<div class="relative">
|
|
93
|
-
<!--
|
|
93
|
+
<!-- pe-10 overrides the px-3 CONTROL_BASE states, because `pe` is emitted
|
|
94
94
|
after `px` and so wins. Without it a long key ran under the button and
|
|
95
95
|
the operator could not read the end of what they had pasted. -->
|
|
96
96
|
<input
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
{onchange}
|
|
108
108
|
aria-invalid={error ? 'true' : undefined}
|
|
109
109
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
110
|
-
class="{CONTROL_BASE} {controlBorder(!!error)} {showToggle ? '
|
|
110
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} {showToggle ? 'pe-10' : ''} {controlClass}"
|
|
111
111
|
/>
|
|
112
112
|
|
|
113
113
|
{#if showToggle}
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
aria-pressed={revealed}
|
|
121
121
|
aria-controls={fieldId}
|
|
122
122
|
aria-label={revealed ? 'Hide password' : 'Show password'}
|
|
123
|
-
class="absolute top-1/2
|
|
123
|
+
class="absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
124
124
|
>
|
|
125
125
|
{#if revealed}
|
|
126
126
|
<EyeOff size={15} aria-hidden="true" />
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
own text it became part of the name, and the reader heard the
|
|
146
146
|
whole paragraph before it reached the selected state. -->
|
|
147
147
|
<span id="{fieldId}-label" class="{choiceLabel(size)} {labelHidden ? 'sr-only' : ''}">
|
|
148
|
-
{label}{#if required}<span class="
|
|
148
|
+
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
149
149
|
</span>
|
|
150
150
|
{/if}
|
|
151
151
|
</span>
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
ahead of them saying what the set is for.
|
|
110
110
|
-->
|
|
111
111
|
<legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
112
|
-
{label}{#if required}<span class="text-danger
|
|
112
|
+
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
113
113
|
</legend>
|
|
114
114
|
|
|
115
115
|
<div class={choiceGroupList(orientation)}>
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
stroke-width="2"
|
|
52
52
|
stroke-linecap="round"
|
|
53
53
|
stroke-linejoin="round"
|
|
54
|
-
class="pointer-events-none absolute
|
|
54
|
+
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-faint"
|
|
55
55
|
aria-hidden="true"
|
|
56
56
|
>
|
|
57
57
|
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
{disabled}
|
|
66
66
|
{value}
|
|
67
67
|
oninput={handleInput}
|
|
68
|
-
class="{CONTROL_BASE} {controlBorder(false)}
|
|
68
|
+
class="{CONTROL_BASE} {controlBorder(false)} ps-9
|
|
69
69
|
[&::-webkit-search-cancel-button]:appearance-none
|
|
70
|
-
{value ? '
|
|
70
|
+
{value ? 'pe-8' : 'pe-3'}"
|
|
71
71
|
/>
|
|
72
72
|
|
|
73
73
|
{#if value}
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
type="button"
|
|
85
85
|
onclick={clear}
|
|
86
86
|
aria-label="Clear search"
|
|
87
|
-
class="absolute
|
|
87
|
+
class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
|
|
88
88
|
transition-colors duration-150 hover:text-fg"
|
|
89
89
|
>
|
|
90
90
|
<svg
|
|
@@ -164,7 +164,9 @@
|
|
|
164
164
|
onkeydown={(e) => onSegmentKeydown(e, i)}
|
|
165
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
166
|
size
|
|
167
|
-
].segment} {selected
|
|
167
|
+
].segment} {selected
|
|
168
|
+
? 'bg-surface text-fg shadow-sm'
|
|
169
|
+
: 'text-muted hover:text-fg active:bg-surface active:text-fg'}"
|
|
168
170
|
>
|
|
169
171
|
{#if Icon}
|
|
170
172
|
<Icon size={SIZES[size].icon} class="shrink-0" />
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
|
|
275
275
|
{#snippet chevron(open: boolean)}
|
|
276
276
|
<span
|
|
277
|
-
class="pointer-events-none absolute
|
|
277
|
+
class="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-faint"
|
|
278
278
|
aria-hidden="true"
|
|
279
279
|
>
|
|
280
280
|
<svg
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
height="12"
|
|
329
329
|
viewBox="0 0 12 12"
|
|
330
330
|
fill="none"
|
|
331
|
-
class="
|
|
331
|
+
class="ms-auto shrink-0"
|
|
332
332
|
aria-hidden="true"
|
|
333
333
|
>
|
|
334
334
|
<path
|
|
@@ -346,7 +346,7 @@
|
|
|
346
346
|
<div class="{FIELD_WRAP} {cls}">
|
|
347
347
|
{#if label}
|
|
348
348
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
349
|
-
{label}{#if required}<span class="text-danger
|
|
349
|
+
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
350
350
|
</label>
|
|
351
351
|
{/if}
|
|
352
352
|
|
|
@@ -381,8 +381,8 @@
|
|
|
381
381
|
aria-required={required ? 'true' : undefined}
|
|
382
382
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
383
383
|
{...box.triggerAttrs}
|
|
384
|
-
class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2
|
|
385
|
-
text-
|
|
384
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
|
|
385
|
+
text-start"
|
|
386
386
|
>
|
|
387
387
|
{#if trigger}
|
|
388
388
|
{@render trigger({ selected, open: box.open })}
|
|
@@ -472,7 +472,7 @@
|
|
|
472
472
|
onchange={nativeChange}
|
|
473
473
|
aria-invalid={error ? 'true' : undefined}
|
|
474
474
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
475
|
-
class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none
|
|
475
|
+
class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none pe-8"
|
|
476
476
|
>
|
|
477
477
|
{#if children}
|
|
478
478
|
{@render children()}
|
|
@@ -69,11 +69,25 @@
|
|
|
69
69
|
'transition-colors duration-150 outline-none ' +
|
|
70
70
|
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Exactly one border colour, one background and one text colour per state.
|
|
74
|
+
*
|
|
75
|
+
* Every state answers the pointer, including the current one. It did not: the
|
|
76
|
+
* current row was the only paint with no hover step, and the current row is
|
|
77
|
+
* the tail of the active trail, so on any nav deep enough to have sections it
|
|
78
|
+
* is a nested entry. Beside its own siblings, which all lit up, the row for
|
|
79
|
+
* the page you were on read as the one dead entry in the list.
|
|
80
|
+
*
|
|
81
|
+
* The held step is separate from the hovered one because `hover:` compiles
|
|
82
|
+
* inside `@media (hover: hover)` and never matches a finger. A sidebar is
|
|
83
|
+
* mostly used on a phone as a drawer, where the tap that closed it was the
|
|
84
|
+
* one gesture in the component with nothing on screen behind it.
|
|
85
|
+
*/
|
|
73
86
|
function rowPaint(state: 'current' | 'ancestor' | 'rest'): string {
|
|
74
|
-
if (state === 'current')
|
|
75
|
-
|
|
76
|
-
return 'border-
|
|
87
|
+
if (state === 'current')
|
|
88
|
+
return 'border-brand bg-surface-2 font-medium text-brand hover:bg-line active:bg-line';
|
|
89
|
+
if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2 active:bg-line';
|
|
90
|
+
return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg';
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
const ROW_DISABLED = 'cursor-not-allowed border-transparent text-muted opacity-50';
|
|
@@ -87,6 +101,7 @@
|
|
|
87
101
|
const DISCLOSURE =
|
|
88
102
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ' +
|
|
89
103
|
'transition-colors duration-150 outline-none hover:bg-surface-2 hover:text-fg ' +
|
|
104
|
+
'active:bg-line active:text-fg ' +
|
|
90
105
|
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
91
106
|
|
|
92
107
|
const BADGE_TONE: Record<AccentTone, string> = {
|
|
@@ -257,7 +272,7 @@
|
|
|
257
272
|
stroke-linecap="round"
|
|
258
273
|
stroke-linejoin="round"
|
|
259
274
|
aria-hidden="true"
|
|
260
|
-
class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : ''}"
|
|
275
|
+
class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : 'rtl:rotate-180'}"
|
|
261
276
|
>
|
|
262
277
|
<path d="M9 18l6-6-6-6" />
|
|
263
278
|
</svg>
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* How a body cell treats content wider than its column.
|
|
4
|
+
*
|
|
5
|
+
* `wrap` breaks anywhere it has to. `truncate` keeps the cell to one line and
|
|
6
|
+
* ends it with an ellipsis. `auto` is the browser's own table algorithm, with
|
|
7
|
+
* no cap of any kind.
|
|
8
|
+
*/
|
|
9
|
+
export type CellFit = 'auto' | 'wrap' | 'truncate';
|
|
10
|
+
</script>
|
|
11
|
+
|
|
1
12
|
<script lang="ts">
|
|
2
13
|
import type { Snippet } from 'svelte';
|
|
3
14
|
|
|
@@ -12,6 +23,35 @@
|
|
|
12
23
|
* else, which is a landmark the reader has to enter to identify.
|
|
13
24
|
*/
|
|
14
25
|
label?: string;
|
|
26
|
+
/**
|
|
27
|
+
* What a body cell does with content too wide for its column.
|
|
28
|
+
*
|
|
29
|
+
* `wrap` by default, and the default is not `auto`. A table constrained
|
|
30
|
+
* nothing: one cell holding an unbroken token - an API key, a signed URL,
|
|
31
|
+
* a base64 payload - has no break opportunity in it, so the auto table
|
|
32
|
+
* algorithm sizes the column to the whole string and the table grows to
|
|
33
|
+
* whatever that comes to. One measured page took its container from no
|
|
34
|
+
* overflow at all to 3,086px of it, and the audit log overran its own
|
|
35
|
+
* container by 419px at 390px wide.
|
|
36
|
+
*
|
|
37
|
+
* `wrap` fixes exactly that and nothing else. It is `overflow-wrap:
|
|
38
|
+
* anywhere`, which is the one value that shrinks the column's min-content
|
|
39
|
+
* width, so a paragraph still breaks at its spaces and only a token with
|
|
40
|
+
* no spaces in it is broken mid-string. Prose in a cell renders as it
|
|
41
|
+
* always did.
|
|
42
|
+
*
|
|
43
|
+
* The default wraps rather than truncates because wrapping is the choice
|
|
44
|
+
* that cannot lose anything. Truncation hides, and a table that hides is
|
|
45
|
+
* worse than one that scrolls, so it is opted into.
|
|
46
|
+
*/
|
|
47
|
+
cell?: CellFit;
|
|
48
|
+
/**
|
|
49
|
+
* The width a truncating cell stops at. Any CSS length.
|
|
50
|
+
*
|
|
51
|
+
* Read through a custom property, so a single column can disagree with the
|
|
52
|
+
* table by setting `--cell-truncate` in its own style attribute.
|
|
53
|
+
*/
|
|
54
|
+
truncateAt?: string;
|
|
15
55
|
class?: string;
|
|
16
56
|
children: Snippet;
|
|
17
57
|
}
|
|
@@ -21,6 +61,8 @@
|
|
|
21
61
|
hoverable = true,
|
|
22
62
|
fixed = false,
|
|
23
63
|
label = undefined,
|
|
64
|
+
cell = 'wrap',
|
|
65
|
+
truncateAt = '20rem',
|
|
24
66
|
class: cls = '',
|
|
25
67
|
children,
|
|
26
68
|
}: Props = $props();
|
|
@@ -99,6 +141,73 @@
|
|
|
99
141
|
* for the same reason and it reads in both themes.
|
|
100
142
|
*/
|
|
101
143
|
const EDGE = 'pointer-events-none absolute inset-y-px w-6';
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* The table-wide treatment, applied only to cells that did not ask for their
|
|
147
|
+
* own.
|
|
148
|
+
*
|
|
149
|
+
* `:not([data-cell])` is what keeps the two apart. Without it a column that
|
|
150
|
+
* opted out still matched the table's rule for the same property, and which
|
|
151
|
+
* of the two won came down to the order Tailwind happened to emit them in.
|
|
152
|
+
*/
|
|
153
|
+
const CELL_FIT: Record<CellFit, string> = {
|
|
154
|
+
auto: '',
|
|
155
|
+
wrap: '[&_tbody_td:not([data-cell])]:[overflow-wrap:anywhere]',
|
|
156
|
+
truncate:
|
|
157
|
+
'[&_tbody_td:not([data-cell])]:max-w-[var(--cell-truncate)] [&_tbody_td:not([data-cell])]:truncate',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
* The per-cell opt-in, always present so a caller can mix treatments down one
|
|
162
|
+
* row: `data-cell="nowrap"` on a timestamp, `data-cell="truncate"` on a
|
|
163
|
+
* description, nothing at all on the rest.
|
|
164
|
+
*/
|
|
165
|
+
const CELL_NAMED =
|
|
166
|
+
'[&_tbody_td[data-cell=wrap]]:[overflow-wrap:anywhere] ' +
|
|
167
|
+
'[&_tbody_td[data-cell=nowrap]]:whitespace-nowrap ' +
|
|
168
|
+
'[&_tbody_td[data-cell=truncate]]:max-w-[var(--cell-truncate)] ' +
|
|
169
|
+
'[&_tbody_td[data-cell=truncate]]:truncate';
|
|
170
|
+
|
|
171
|
+
/** Marks a title this component put there, so it can take it back again. */
|
|
172
|
+
const OWNED = 'data-cell-title';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Gives a clipped cell its full value back.
|
|
176
|
+
*
|
|
177
|
+
* The text is never removed from the document, so a screen reader already
|
|
178
|
+
* reads the whole of it and it is the sighted pointer user who loses the tail
|
|
179
|
+
* to the ellipsis. The title that returns it is attached when a pointer or
|
|
180
|
+
* the keyboard actually reaches the cell, from one listener on the scroll
|
|
181
|
+
* box, rather than measured for every cell as the table renders: a tenant
|
|
182
|
+
* with 10,000 rows would pay for 10,000 measurements and 10,000 title
|
|
183
|
+
* attributes to answer a question asked of about three of them.
|
|
184
|
+
*/
|
|
185
|
+
function reveal(event: Event) {
|
|
186
|
+
const from = event.target;
|
|
187
|
+
if (!(from instanceof Element)) return;
|
|
188
|
+
const td = from.closest('td');
|
|
189
|
+
if (!(td instanceof HTMLElement)) return;
|
|
190
|
+
|
|
191
|
+
const named = td.dataset.cell;
|
|
192
|
+
const clipping = named === 'truncate' || (named === undefined && cell === 'truncate');
|
|
193
|
+
if (!clipping) return;
|
|
194
|
+
|
|
195
|
+
const ours = td.hasAttribute(OWNED);
|
|
196
|
+
// A title the caller wrote is theirs. It may well say more than the cell
|
|
197
|
+
// holds, and replacing it with the visible text would be a downgrade.
|
|
198
|
+
if (td.hasAttribute('title') && !ours) return;
|
|
199
|
+
|
|
200
|
+
const full = (td.textContent ?? '').trim();
|
|
201
|
+
if (full && td.scrollWidth - td.clientWidth > 1) {
|
|
202
|
+
td.setAttribute('title', full);
|
|
203
|
+
td.setAttribute(OWNED, '');
|
|
204
|
+
} else if (ours) {
|
|
205
|
+
// The column widened, or the row now holds something shorter. A tooltip
|
|
206
|
+
// repeating text the reader can already see is noise.
|
|
207
|
+
td.removeAttribute('title');
|
|
208
|
+
td.removeAttribute(OWNED);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
102
211
|
</script>
|
|
103
212
|
|
|
104
213
|
<div data-testid="table-frame" class="relative w-full rounded-xl border border-line {cls}">
|
|
@@ -109,6 +218,8 @@
|
|
|
109
218
|
<div
|
|
110
219
|
bind:this={scroller}
|
|
111
220
|
onscroll={measure}
|
|
221
|
+
onpointerover={reveal}
|
|
222
|
+
onfocusin={reveal}
|
|
112
223
|
data-print="unclip"
|
|
113
224
|
data-testid="table-scroll"
|
|
114
225
|
class="w-full overflow-x-auto rounded-xl"
|
|
@@ -126,8 +237,10 @@
|
|
|
126
237
|
same place.
|
|
127
238
|
-->
|
|
128
239
|
<table
|
|
129
|
-
|
|
240
|
+
style="--cell-truncate: {truncateAt}"
|
|
241
|
+
class="w-full border-separate border-spacing-0 text-sm text-start
|
|
130
242
|
{fixed ? 'table-fixed' : ''}
|
|
243
|
+
{CELL_FIT[cell]} {CELL_NAMED}
|
|
131
244
|
[&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
|
|
132
245
|
[&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
|
|
133
246
|
[&_thead_th]:font-medium [&_thead_th]:text-faint
|