@lyeve-labs/ui-kit 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +11 -4
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/AppShell.svelte +3 -3
  4. package/dist/components/Autocomplete.svelte +4 -4
  5. package/dist/components/AvatarGroup.svelte +2 -2
  6. package/dist/components/Breadcrumb.svelte +1 -1
  7. package/dist/components/Button.svelte +34 -9
  8. package/dist/components/ButtonGroup.svelte +3 -3
  9. package/dist/components/Card.svelte +1 -1
  10. package/dist/components/Checkbox.svelte +1 -1
  11. package/dist/components/CheckboxGroup.svelte +1 -1
  12. package/dist/components/Collapsible.svelte +3 -3
  13. package/dist/components/CopyButton.svelte +1 -1
  14. package/dist/components/DatePicker.svelte +2 -2
  15. package/dist/components/DateTimePicker.svelte +1 -1
  16. package/dist/components/Drawer.svelte +1 -1
  17. package/dist/components/Dropdown.svelte +1 -1
  18. package/dist/components/Field.svelte +1 -1
  19. package/dist/components/Input.svelte +1 -1
  20. package/dist/components/Label.svelte +1 -1
  21. package/dist/components/MultiSelect.svelte +4 -4
  22. package/dist/components/NumberInput.svelte +3 -3
  23. package/dist/components/Pagination.svelte +194 -65
  24. package/dist/components/Pagination.svelte.d.ts +52 -2
  25. package/dist/components/PasswordInput.svelte +4 -4
  26. package/dist/components/Radio.svelte +1 -1
  27. package/dist/components/RadioGroup.svelte +1 -1
  28. package/dist/components/SearchInput.svelte +4 -4
  29. package/dist/components/SegmentedControl.svelte +3 -1
  30. package/dist/components/Select.svelte +6 -6
  31. package/dist/components/SidebarNav.svelte +20 -5
  32. package/dist/components/Table.svelte +114 -1
  33. package/dist/components/Table.svelte.d.ts +37 -0
  34. package/dist/components/Tabs.svelte +1 -1
  35. package/dist/components/Tag.svelte +1 -1
  36. package/dist/components/Textarea.svelte +1 -1
  37. package/dist/components/ThemeToggle.svelte +1 -1
  38. package/dist/components/TimePicker.svelte +2 -2
  39. package/dist/components/Toaster.svelte +2 -2
  40. package/dist/components/Toggle.svelte +10 -4
  41. package/dist/components/TreeView.svelte +4 -4
  42. package/dist/components/dialog/Dialog.svelte +1 -1
  43. package/dist/index.d.ts +2 -1
  44. package/dist/index.js +1 -1
  45. package/dist/internal/href.d.ts +13 -0
  46. package/dist/internal/href.js +27 -0
  47. package/dist/internal/number.d.ts +26 -0
  48. package/dist/internal/number.js +35 -0
  49. package/dist/internal/panel.js +1 -1
  50. package/dist/styles/theme.css +53 -0
  51. package/package.json +3 -4
  52. package/src/lib/styles/theme.css +53 -0
@@ -1,24 +1,88 @@
1
1
  <script lang="ts">
2
+ import { safeHref } from '../internal/href.js';
3
+ import { formatCount } from '../internal/number.js';
4
+
5
+ interface Base {
6
+ page: number;
7
+ /**
8
+ * Collection size, when the endpoint states one.
9
+ *
10
+ * Leave it out for a list whose endpoint reports no count and say `hasNext`
11
+ * instead. Absent is not zero: a caller who does state a total, even a
12
+ * nonsense one, gets the counted pager with that total clamped.
13
+ */
14
+ total?: number;
15
+ perPage?: number;
16
+ /**
17
+ * Whether a page after this one exists.
18
+ *
19
+ * Read only when `total` is absent, because a counted list already knows
20
+ * where it ends. It is the answer an endpoint can give without counting the
21
+ * collection: ask for one row more than the page holds and see if it comes
22
+ * back.
23
+ */
24
+ hasNext?: boolean;
25
+ /**
26
+ * What the list holds, in the plural: 'files', 'jobs', 'webhooks'.
27
+ *
28
+ * The summary names it, because a console that stacks three lists under one
29
+ * page title prints '51 to 100' three times and says of what nowhere. Left
30
+ * out, the summary reads exactly as it did before this existed.
31
+ *
32
+ * Plural at every size, including a list of one. The noun labels the
33
+ * collection here rather than agreeing with a figure in the sentence, the
34
+ * same way the column header above it does, and a singular form would have
35
+ * to pick which of the three figures governs it.
36
+ */
37
+ noun?: string;
38
+ class?: string;
39
+ }
40
+
41
+ /**
42
+ * How the pager moves, and why it takes both spellings.
43
+ *
44
+ * `onchange` cannot run before the page hydrates, so a pager that has only a
45
+ * callback is inert on the first paint and an early click lands on nothing.
46
+ * That is not a rare state: it is every server-rendered document, for as long
47
+ * as the bundle takes to arrive. A caller that has to page from that document
48
+ * supplies `href` instead and gets links, which the browser follows with no
49
+ * script at all.
50
+ *
51
+ * Exclusive rather than both together: an anchor whose click also ran the
52
+ * callback would page twice, once through the handler and again through the
53
+ * navigation it did not cancel.
54
+ */
55
+ type Nav =
56
+ | { onchange: (page: number) => void; href?: undefined }
57
+ | { href: (page: number) => string; onchange?: undefined };
58
+
2
59
  let {
3
60
  page,
4
- total,
61
+ total = undefined,
5
62
  perPage = 20,
63
+ hasNext = false,
64
+ noun = '',
6
65
  class: cls = '',
7
66
  onchange,
8
- }: {
9
- page: number;
10
- total: number;
11
- perPage?: number;
12
- class?: string;
13
- onchange: (page: number) => void;
14
- } = $props();
67
+ href,
68
+ }: Base & Nav = $props();
15
69
 
16
- let safeTotal = $derived(isFinite(total) && total >= 0 ? total : 0);
70
+ /**
71
+ * A stated total is what makes the numbered pages knowable. Without one the
72
+ * pager can offer the step either side of where it stands and nothing more,
73
+ * because the last page is not a number anybody here has.
74
+ */
75
+ let counted = $derived(total !== undefined);
76
+ let safeTotal = $derived(typeof total === 'number' && isFinite(total) && total >= 0 ? total : 0);
17
77
  let safePage = $derived(isFinite(page) && page >= 1 ? page : 1);
18
78
  let totalPages = $derived(Math.max(1, Math.ceil(safeTotal / perPage)));
19
79
  let from = $derived(Math.min((safePage - 1) * perPage + 1, safeTotal));
20
80
  let to = $derived(Math.min(safePage * perPage, safeTotal));
21
81
 
82
+ let canPrev = $derived(safePage > 1);
83
+ let canNext = $derived(counted ? safePage < totalPages : hasNext);
84
+ let hasControls = $derived(counted ? totalPages > 1 : canPrev || canNext);
85
+
22
86
  /**
23
87
  * The elided run is the sentinel string 'gap', never the horizontal-ellipsis
24
88
  * character.
@@ -51,46 +115,127 @@
51
115
  return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
52
116
  }
53
117
 
54
- let nums = $derived(pageNumbers(safePage, totalPages));
118
+ let nums = $derived(counted ? pageNumbers(safePage, totalPages) : []);
119
+
120
+ /**
121
+ * The noun as it will be read.
122
+ *
123
+ * Trimmed, and empty means absent: a caller composing the word from data can
124
+ * hand over ' ' as easily as 'files', and a summary that opens with a stray
125
+ * space is worse than one that names nothing.
126
+ */
127
+ let label = $derived(noun.trim());
128
+
129
+ /**
130
+ * The count, when there is one to state.
131
+ *
132
+ * An uncounted list says which page it is on and nothing else. It cannot say
133
+ * "1 to 20 of 400" without the total, and it cannot say "1 to 20" either: the
134
+ * last page is short and this component is never told how short. It still
135
+ * groups the page number: `href` mode puts that number in the URL, so a link
136
+ * or a reader can land on page 5,000 without stepping there.
137
+ *
138
+ * Every figure is grouped, because a pager sits under a table that groups its
139
+ * own figures and the pager is where the largest number on the screen is.
140
+ */
141
+ let summary = $derived.by(() => {
142
+ if (!counted) {
143
+ return label ? `${label}, page ${formatCount(safePage)}` : `Page ${formatCount(safePage)}`;
144
+ }
145
+ if (safeTotal === 0) return label ? `No ${label}` : 'No results';
146
+ const range = `${formatCount(from)} to ${formatCount(to)} of ${formatCount(safeTotal)}`;
147
+ return label ? `${label} ${range}` : range;
148
+ });
149
+
150
+ /** The target of a control, sanitized, or nothing when the step is unavailable. */
151
+ function linkTo(target: number, enabled: boolean): string | undefined {
152
+ return href && enabled ? safeHref(href(target)) : undefined;
153
+ }
55
154
 
56
155
  const btnBase =
57
156
  'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
157
+
158
+ /**
159
+ * A page control that is not the page you are on, in every state a pointer
160
+ * can put it in.
161
+ *
162
+ * Held as well as hovered, because `hover:` never matches on a touch screen
163
+ * and paging is a thumb gesture: without the pressed step a tap on a phone
164
+ * changed the page with nothing on the control to say it had been hit.
165
+ *
166
+ * Stated once rather than spelled out on the previous button, the next button
167
+ * and every numbered one. It was written three times, and a state added to
168
+ * one copy is a state missing from two.
169
+ */
170
+ const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
171
+
172
+ const btnCurrent = 'bg-brand text-ink';
173
+
174
+ /**
175
+ * A link with nowhere to go.
176
+ *
177
+ * An anchor has no `disabled`, and `disabled:` never matches one, so the two
178
+ * states have to be painted rather than declared. Dropping the href is what
179
+ * takes it out of the tab order and stops activation; the class is only the
180
+ * part a reader can see, at the same 30% the disabled buttons use.
181
+ */
182
+ const linkInert = 'pointer-events-none opacity-30';
58
183
  </script>
59
184
 
185
+ {#snippet arrow(d: string)}
186
+ <svg
187
+ width="14"
188
+ height="14"
189
+ viewBox="0 0 24 24"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ stroke-width="2"
193
+ stroke-linecap="round"
194
+ stroke-linejoin="round"
195
+ aria-hidden="true"
196
+ class="rtl:rotate-180"
197
+ >
198
+ <path {d} />
199
+ </svg>
200
+ {/snippet}
201
+
202
+ <!-- Previous and next, as a link when the caller builds hrefs and as a button
203
+ when it hands over a callback. The two branches carry the same classes and
204
+ the same label, so the pager reads and sounds the same either way. -->
205
+ {#snippet step(target: number, label: string, enabled: boolean, d: string)}
206
+ {#if href}
207
+ <a
208
+ href={linkTo(target, enabled)}
209
+ aria-label={label}
210
+ aria-disabled={enabled ? undefined : 'true'}
211
+ class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
212
+ >
213
+ {@render arrow(d)}
214
+ </a>
215
+ {:else}
216
+ <button
217
+ type="button"
218
+ disabled={!enabled}
219
+ onclick={() => onchange?.(target)}
220
+ aria-label={label}
221
+ class="{btnBase} {btnRest} disabled:cursor-not-allowed disabled:opacity-30"
222
+ >
223
+ {@render arrow(d)}
224
+ </button>
225
+ {/if}
226
+ {/snippet}
227
+
60
228
  <!-- The summary renders whenever there is a count to state, and the page
61
229
  buttons only when there is more than one page. The whole component used to
62
230
  be behind `totalPages > 1`, and an empty list has one page, so the 'No
63
231
  results' line below could never appear and a single page of results showed
64
232
  no count at all. -->
65
233
  <div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
66
- <span class="text-xs text-faint shrink-0">
67
- {safeTotal === 0 ? 'No results' : `${from} to ${to} of ${safeTotal}`}
68
- </span>
69
-
70
- {#if totalPages > 1}
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>
234
+ <span class="shrink-0 text-xs text-faint">{summary}</span>
235
+
236
+ {#if hasControls}
237
+ <div class="ms-auto flex flex-wrap items-center gap-0.5">
238
+ {@render step(safePage - 1, 'Previous page', canPrev, 'M15 18l-6-6 6-6')}
94
239
 
95
240
  {#each nums as n, i (slotKey(n, i))}
96
241
  {#if n === 'gap'}
@@ -111,43 +256,27 @@
111
256
  <path d="M5 12h.01M12 12h.01M19 12h.01" />
112
257
  </svg>
113
258
  </span>
259
+ {:else if href}
260
+ <a
261
+ href={linkTo(n, true)}
262
+ aria-current={safePage === n ? 'page' : undefined}
263
+ class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
264
+ >
265
+ {n}
266
+ </a>
114
267
  {:else}
115
268
  <button
116
269
  type="button"
117
- onclick={() => onchange(n as number)}
270
+ onclick={() => onchange?.(n as number)}
118
271
  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'}"
272
+ class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
123
273
  >
124
274
  {n}
125
275
  </button>
126
276
  {/if}
127
277
  {/each}
128
278
 
129
- <button
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>
279
+ {@render step(safePage + 1, 'Next page', canNext, 'M9 18l6-6-6-6')}
151
280
  </div>
152
281
  {/if}
153
282
  </div>
@@ -1,10 +1,60 @@
1
- type $$ComponentProps = {
1
+ interface Base {
2
2
  page: number;
3
- total: number;
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;
21
+ /**
22
+ * What the list holds, in the plural: 'files', 'jobs', 'webhooks'.
23
+ *
24
+ * The summary names it, because a console that stacks three lists under one
25
+ * page title prints '51 to 100' three times and says of what nowhere. Left
26
+ * out, the summary reads exactly as it did before this existed.
27
+ *
28
+ * Plural at every size, including a list of one. The noun labels the
29
+ * collection here rather than agreeing with a figure in the sentence, the
30
+ * same way the column header above it does, and a singular form would have
31
+ * to pick which of the three figures governs it.
32
+ */
33
+ noun?: string;
5
34
  class?: string;
35
+ }
36
+ /**
37
+ * How the pager moves, and why it takes both spellings.
38
+ *
39
+ * `onchange` cannot run before the page hydrates, so a pager that has only a
40
+ * callback is inert on the first paint and an early click lands on nothing.
41
+ * That is not a rare state: it is every server-rendered document, for as long
42
+ * as the bundle takes to arrive. A caller that has to page from that document
43
+ * supplies `href` instead and gets links, which the browser follows with no
44
+ * script at all.
45
+ *
46
+ * Exclusive rather than both together: an anchor whose click also ran the
47
+ * callback would page twice, once through the handler and again through the
48
+ * navigation it did not cancel.
49
+ */
50
+ type Nav = {
6
51
  onchange: (page: number) => void;
52
+ href?: undefined;
53
+ } | {
54
+ href: (page: number) => string;
55
+ onchange?: undefined;
7
56
  };
57
+ type $$ComponentProps = Base & Nav;
8
58
  declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "">;
9
59
  type Pagination = ReturnType<typeof Pagination>;
10
60
  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="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
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
- <!-- pr-10 overrides the px-3 CONTROL_BASE states, because `pr` is emitted
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 ? 'pr-10' : ''} {controlClass}"
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 right-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"
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="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
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 ml-0.5" aria-hidden="true">*</span>{/if}
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 left-3 top-1/2 -translate-y-1/2 text-faint"
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)} pl-9
68
+ class="{CONTROL_BASE} {controlBorder(false)} ps-9
69
69
  [&::-webkit-search-cancel-button]:appearance-none
70
- {value ? 'pr-8' : 'pr-3'}"
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 right-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
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 ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg'}"
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 right-2.5 top-1/2 -translate-y-1/2 text-faint"
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="ml-auto shrink-0"
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 ml-0.5" aria-hidden="true">*</span>{/if}
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 pr-8
385
- text-left"
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 pr-8"
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
- /** Exactly one border colour, one background and one text colour per state. */
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') return 'border-brand bg-surface-2 font-medium text-brand';
75
- if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2';
76
- return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg';
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>