@lyeve-labs/ui-kit 0.17.1 → 0.18.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 +16 -13
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/AccountMenu.svelte +1 -1
- package/dist/components/AppShell.svelte +20 -6
- package/dist/components/Autocomplete.svelte +10 -1
- package/dist/components/Card.svelte +1 -0
- package/dist/components/Collapsible.svelte +1 -1
- package/dist/components/DatePicker.svelte +1 -1
- package/dist/components/Drawer.svelte +6 -26
- package/dist/components/Dropdown.svelte +196 -10
- package/dist/components/Dropdown.svelte.d.ts +12 -0
- package/dist/components/Logo.svelte +77 -0
- package/dist/components/Logo.svelte.d.ts +35 -0
- package/dist/components/Modal.svelte +2 -15
- package/dist/components/MultiSelect.svelte +12 -3
- package/dist/components/PageHeader.svelte +23 -1
- package/dist/components/Panel.svelte +1 -1
- package/dist/components/SearchInput.svelte +10 -1
- package/dist/components/Table.svelte +143 -10
- package/dist/components/Table.svelte.d.ts +7 -0
- package/dist/components/ThemeToggle.svelte +75 -12
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/Toaster.svelte +3 -15
- package/dist/components/Tooltip.svelte +1 -1
- package/dist/components/dialog/Dialog.svelte +9 -4
- package/dist/components/dialog/dialog-manager.svelte.d.ts +20 -1
- package/dist/components/dialog/dialog-manager.svelte.js +13 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -2
- package/dist/internal/layout.js +11 -2
- package/dist/internal/panel.d.ts +1 -1
- package/dist/internal/panel.js +1 -1
- package/dist/styles/theme.css +235 -0
- package/dist/utils/theme.d.ts +68 -3
- package/dist/utils/theme.js +118 -3
- package/package.json +18 -19
- package/src/lib/styles/theme.css +235 -0
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
|
|
|
28
28
|
|
|
29
29
|
## What's in the box
|
|
30
30
|
|
|
31
|
-
- **
|
|
31
|
+
- **67 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
|
|
32
32
|
- **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
|
|
33
33
|
- **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token; the rest is just Tailwind.
|
|
34
34
|
- **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
|
|
@@ -37,28 +37,31 @@ No config file, no theme provider, no setup ceremony.
|
|
|
37
37
|
## Component list
|
|
38
38
|
|
|
39
39
|
<details>
|
|
40
|
-
<summary>
|
|
40
|
+
<summary>67 components, organized by purpose</summary>
|
|
41
41
|
|
|
42
|
-
**Layout
|
|
43
|
-
Card
|
|
42
|
+
**Layout and structure**
|
|
43
|
+
Card, Panel, AppShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
|
|
44
44
|
|
|
45
|
-
**Forms**
|
|
46
|
-
Button
|
|
45
|
+
**Forms and inputs**
|
|
46
|
+
Button, ButtonGroup, Input, PasswordInput, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
|
|
47
47
|
|
|
48
48
|
**Navigation**
|
|
49
|
-
Breadcrumb
|
|
49
|
+
Breadcrumb, Tabs, Pagination, StepIndicator, Dropdown, SidebarNav, AccountMenu
|
|
50
50
|
|
|
51
51
|
**Overlays**
|
|
52
|
-
Modal
|
|
52
|
+
Modal, Drawer, Tooltip
|
|
53
53
|
|
|
54
|
-
**
|
|
55
|
-
|
|
54
|
+
**Dialogs**
|
|
55
|
+
Dialog, DialogContainer, ConfirmDialog (plus the `openDialog` and `confirm` services)
|
|
56
|
+
|
|
57
|
+
**Feedback and status**
|
|
58
|
+
Alert, Banner, Badge, Tag, Indicator, Progress, Spinner, Skeleton, EmptyState, Stat, Kbd, CopyButton
|
|
56
59
|
|
|
57
60
|
**Media**
|
|
58
|
-
Avatar
|
|
61
|
+
Avatar, AvatarGroup, Logo
|
|
59
62
|
|
|
60
63
|
**Theming**
|
|
61
|
-
ThemeToggle
|
|
64
|
+
ThemeToggle, Toaster (plus the `toast` service)
|
|
62
65
|
|
|
63
66
|
</details>
|
|
64
67
|
|
|
@@ -175,7 +178,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
|
|
|
175
178
|
```
|
|
176
179
|
src/
|
|
177
180
|
└── lib/ # → published as @lyeve-labs/ui-kit
|
|
178
|
-
├── components/ #
|
|
181
|
+
├── components/ # 67 .svelte files
|
|
179
182
|
├── stores/ # toast.svelte.ts
|
|
180
183
|
├── styles/ # theme.css (the one stylesheet)
|
|
181
184
|
├── utils/ # cn.ts, theme.ts
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
clipped rather than spilling while the row grows.
|
|
67
67
|
-->
|
|
68
68
|
<div
|
|
69
|
-
class="grid transition-[grid-template-rows] duration-
|
|
69
|
+
class="grid transition-[grid-template-rows] duration-[var(--duration-collapse)] ease-out {open
|
|
70
70
|
? 'grid-rows-[1fr]'
|
|
71
71
|
: 'grid-rows-[0fr]'}"
|
|
72
72
|
>
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
rule are the card treatment, because lifted off the header it is a
|
|
121
121
|
surface of its own. -->
|
|
122
122
|
<div
|
|
123
|
-
class="absolute end-0 top-full z-
|
|
123
|
+
class="absolute end-0 top-full z-dropdown mt-2 flex w-64 flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl"
|
|
124
124
|
>
|
|
125
125
|
<div class="border-b border-line px-2 pb-2">
|
|
126
126
|
<p class="truncate text-sm font-medium text-fg">{name}</p>
|
|
@@ -110,12 +110,13 @@
|
|
|
110
110
|
|
|
111
111
|
<svelte:window onkeydown={onWindowKeydown} />
|
|
112
112
|
|
|
113
|
-
<div class="flex h-screen {klass}">
|
|
113
|
+
<div data-print="unclip" class="flex h-screen {klass}">
|
|
114
114
|
<!-- First focusable thing in the document. Without it a keyboard reader tabs
|
|
115
115
|
the whole sidebar again on every page. -->
|
|
116
116
|
<a
|
|
117
117
|
href="#content"
|
|
118
|
-
|
|
118
|
+
data-print="hide"
|
|
119
|
+
class="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-skip-link focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:text-fg focus:outline-none focus:ring-2 focus:ring-brand"
|
|
119
120
|
>
|
|
120
121
|
Skip to content
|
|
121
122
|
</a>
|
|
@@ -125,7 +126,8 @@
|
|
|
125
126
|
themes. Closing on it is the gesture people expect, and it stops a tap
|
|
126
127
|
reaching the page under the open drawer. -->
|
|
127
128
|
<div
|
|
128
|
-
|
|
129
|
+
data-print="hide"
|
|
130
|
+
class="fixed inset-0 z-overlay bg-black/60 backdrop-blur-sm"
|
|
129
131
|
role="presentation"
|
|
130
132
|
onclick={() => (navOpen = false)}
|
|
131
133
|
></div>
|
|
@@ -145,7 +147,8 @@
|
|
|
145
147
|
-->
|
|
146
148
|
{#if drawerOpen}
|
|
147
149
|
<div
|
|
148
|
-
|
|
150
|
+
data-print="hide"
|
|
151
|
+
class="fixed inset-y-0 start-0 z-drawer flex shadow-2xl"
|
|
149
152
|
role="dialog"
|
|
150
153
|
aria-modal="true"
|
|
151
154
|
aria-label={drawerLabel}
|
|
@@ -160,7 +163,7 @@
|
|
|
160
163
|
{/if}
|
|
161
164
|
|
|
162
165
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
163
|
-
<header class={APP_HEADER}>
|
|
166
|
+
<header data-print="hide" class={APP_HEADER}>
|
|
164
167
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
165
168
|
{#if isMobile}
|
|
166
169
|
<!-- 44px square, the smallest tap target SC 2.5.5 accepts. The -ms-2
|
|
@@ -201,7 +204,17 @@
|
|
|
201
204
|
{/if}
|
|
202
205
|
</header>
|
|
203
206
|
|
|
204
|
-
|
|
207
|
+
<!-- tabindex -1 is what makes the skip link work. A fragment link scrolls
|
|
208
|
+
to its target and moves focus to it only if the target can hold focus,
|
|
209
|
+
so without this the link moved the page and left focus on the body, and
|
|
210
|
+
the next Tab started again at the top of the document: the sidebar the
|
|
211
|
+
reader had just skipped. -->
|
|
212
|
+
<main
|
|
213
|
+
id="content"
|
|
214
|
+
tabindex="-1"
|
|
215
|
+
data-print="unclip"
|
|
216
|
+
class="min-w-0 flex-1 overflow-auto bg-ink"
|
|
217
|
+
>
|
|
205
218
|
{@render children()}
|
|
206
219
|
</main>
|
|
207
220
|
</div>
|
|
@@ -210,6 +223,7 @@
|
|
|
210
223
|
{#snippet sidebar(hidden: boolean)}
|
|
211
224
|
<aside
|
|
212
225
|
aria-label={sidebarLabel}
|
|
226
|
+
data-print="hide"
|
|
213
227
|
class="{APP_SIDEBAR} flex"
|
|
214
228
|
inert={hidden}
|
|
215
229
|
aria-hidden={hidden ? 'true' : undefined}
|
|
@@ -197,11 +197,20 @@
|
|
|
197
197
|
/>
|
|
198
198
|
|
|
199
199
|
{#if allowClear && value && !disabled}
|
|
200
|
+
<!--
|
|
201
|
+
12px inside 6px of padding is a 24px target, the floor SC 2.5.8 puts on
|
|
202
|
+
a control with no spacing exception to claim, and this one sits inside
|
|
203
|
+
the field. The padding is taken back off the horizontal axis only: the
|
|
204
|
+
box is positioned from its right edge, so a negative right margin holds
|
|
205
|
+
the glyph exactly where it was, while a negative top margin would fight
|
|
206
|
+
the translate that centres it and lift it 6px off the middle of the
|
|
207
|
+
field.
|
|
208
|
+
-->
|
|
200
209
|
<button
|
|
201
210
|
type="button"
|
|
202
211
|
aria-label="Clear"
|
|
203
212
|
onclick={clear}
|
|
204
|
-
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-faint transition-colors duration-150 hover:text-fg"
|
|
213
|
+
class="absolute right-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint transition-colors duration-150 hover:text-fg"
|
|
205
214
|
>
|
|
206
215
|
<svg
|
|
207
216
|
width="12"
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
so the element is interactive; the compiler can't narrow the dynamic role. -->
|
|
83
83
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
84
84
|
<div
|
|
85
|
+
data-print="keep"
|
|
85
86
|
class="bg-surface border border-line rounded-xl overflow-hidden transition-colors duration-150 {hover
|
|
86
87
|
? 'hover:border-line/0 hover:ring-1 hover:ring-brand/30'
|
|
87
88
|
: ''} {onclick ? 'cursor-pointer' : ''} {klass}"
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
clipped rather than spilling while the row grows.
|
|
117
117
|
-->
|
|
118
118
|
<div
|
|
119
|
-
class="grid transition-[grid-template-rows] duration-
|
|
119
|
+
class="grid transition-[grid-template-rows] duration-[var(--duration-collapse)] ease-out {open
|
|
120
120
|
? 'grid-rows-[1fr]'
|
|
121
121
|
: 'grid-rows-[0fr]'}"
|
|
122
122
|
>
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
id={calendarId}
|
|
243
243
|
role="dialog"
|
|
244
244
|
aria-label="Choose date"
|
|
245
|
-
class="absolute z-
|
|
245
|
+
class="absolute z-dropdown mt-1 w-[17rem] rounded-xl border border-line bg-surface shadow-2xl p-3"
|
|
246
246
|
>
|
|
247
247
|
<!-- Header -->
|
|
248
248
|
<div class="flex items-center justify-between mb-2">
|
|
@@ -53,7 +53,10 @@
|
|
|
53
53
|
<svelte:window onkeydown={open ? onkeydown : undefined} />
|
|
54
54
|
|
|
55
55
|
{#if open}
|
|
56
|
-
<div
|
|
56
|
+
<div
|
|
57
|
+
data-print="hide"
|
|
58
|
+
class="fixed inset-0 z-drawer flex {side === 'right' ? 'justify-end' : 'justify-start'}"
|
|
59
|
+
>
|
|
57
60
|
<button
|
|
58
61
|
type="button"
|
|
59
62
|
tabindex="-1"
|
|
@@ -67,8 +70,8 @@
|
|
|
67
70
|
class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
|
|
68
71
|
{side === 'right' ? 'border-l' : 'border-r'} border-line
|
|
69
72
|
{side === 'right'
|
|
70
|
-
? 'animate-[drawer-in-
|
|
71
|
-
: 'animate-[drawer-in-
|
|
73
|
+
? 'animate-[drawer-in-right_var(--duration-drawer-in)_ease-out]'
|
|
74
|
+
: 'animate-[drawer-in-left_var(--duration-drawer-in)_ease-out]'}"
|
|
72
75
|
role="dialog"
|
|
73
76
|
aria-modal="true"
|
|
74
77
|
aria-labelledby={title ? headingId : undefined}
|
|
@@ -117,26 +120,3 @@
|
|
|
117
120
|
</div>
|
|
118
121
|
</div>
|
|
119
122
|
{/if}
|
|
120
|
-
|
|
121
|
-
<style>
|
|
122
|
-
@keyframes drawer-in-right {
|
|
123
|
-
from {
|
|
124
|
-
opacity: 0;
|
|
125
|
-
transform: translateX(16px);
|
|
126
|
-
}
|
|
127
|
-
to {
|
|
128
|
-
opacity: 1;
|
|
129
|
-
transform: translateX(0);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
@keyframes drawer-in-left {
|
|
133
|
-
from {
|
|
134
|
-
opacity: 0;
|
|
135
|
-
transform: translateX(-16px);
|
|
136
|
-
}
|
|
137
|
-
to {
|
|
138
|
-
opacity: 1;
|
|
139
|
-
transform: translateX(0);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
</style>
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A menu behind a trigger of the caller's own.
|
|
4
|
+
*
|
|
5
|
+
* The roles were the whole implementation: `menu` and `menuitem` were stated
|
|
6
|
+
* and nothing else was. A screen reader takes those roles at their word and
|
|
7
|
+
* tells the user to arrow through the items, so it announced a keyboard
|
|
8
|
+
* contract the component did not keep, and the rows were left in the tab
|
|
9
|
+
* sequence instead. That is worse than a plain list of buttons, which at
|
|
10
|
+
* least announces what it is. The contract is kept here: focus moves into the
|
|
11
|
+
* menu when it opens, the arrows and Home and End move it between the items,
|
|
12
|
+
* and Escape and Tab both close and hand focus back to the trigger.
|
|
13
|
+
*/
|
|
2
14
|
import type { Component, Snippet } from 'svelte';
|
|
3
15
|
|
|
4
16
|
interface DropdownItem {
|
|
@@ -23,52 +35,226 @@
|
|
|
23
35
|
|
|
24
36
|
let open = $state(false);
|
|
25
37
|
let containerEl: HTMLDivElement | undefined = $state();
|
|
38
|
+
let menuEl: HTMLDivElement | undefined = $state();
|
|
39
|
+
/** The item focus is resting on. Indexes `items`, disabled rows included. */
|
|
40
|
+
let active = $state(-1);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What counts as the trigger: the first thing in the tab sequence that is not
|
|
44
|
+
* inside the menu. The trigger is a snippet the caller fills, so there is no
|
|
45
|
+
* element here to bind to, and the rows are excluded by position rather than
|
|
46
|
+
* by selector because a caller can put anything focusable in that snippet.
|
|
47
|
+
*/
|
|
48
|
+
const FOCUSABLE =
|
|
49
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
50
|
+
|
|
51
|
+
function triggerNode(): HTMLElement | null {
|
|
52
|
+
if (containerEl === undefined) return null;
|
|
53
|
+
for (const node of containerEl.querySelectorAll<HTMLElement>(FOCUSABLE)) {
|
|
54
|
+
if (menuEl?.contains(node) === true) continue;
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function itemNodes(): HTMLButtonElement[] {
|
|
61
|
+
if (menuEl === undefined) return [];
|
|
62
|
+
return [...menuEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function enabled(index: number): boolean {
|
|
66
|
+
return items[index] !== undefined && items[index].disabled !== true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function first(): number {
|
|
70
|
+
for (let i = 0; i < items.length; i++) if (enabled(i)) return i;
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function last(): number {
|
|
75
|
+
for (let i = items.length - 1; i >= 0; i--) if (enabled(i)) return i;
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The next item in one direction, wrapping past the ends.
|
|
81
|
+
*
|
|
82
|
+
* A menu wraps where a listbox does not: arrowing down from the last item
|
|
83
|
+
* reaches the first, which is what the pattern says and what a reader that
|
|
84
|
+
* has just been told this is a menu will expect. Disabled rows are stepped
|
|
85
|
+
* over, because a disabled button refuses focus and Enter refuses to run it.
|
|
86
|
+
*/
|
|
87
|
+
function step(from: number, direction: 1 | -1): number {
|
|
88
|
+
const count = items.length;
|
|
89
|
+
if (count === 0) return -1;
|
|
90
|
+
let cursor = from < 0 ? (direction === 1 ? -1 : 0) : from;
|
|
91
|
+
for (let taken = 0; taken < count; taken++) {
|
|
92
|
+
cursor = (cursor + direction + count) % count;
|
|
93
|
+
if (enabled(cursor)) return cursor;
|
|
94
|
+
}
|
|
95
|
+
return -1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openMenu(index: number): void {
|
|
99
|
+
active = index;
|
|
100
|
+
open = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function closeMenu(): void {
|
|
104
|
+
open = false;
|
|
105
|
+
active = -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Closes and puts focus back where the user left it.
|
|
110
|
+
*
|
|
111
|
+
* Focus is on a row that is about to be unmounted, so without this it falls
|
|
112
|
+
* to the body and the next Tab starts again from the top of the page.
|
|
113
|
+
*/
|
|
114
|
+
function closeAndReturn(): void {
|
|
115
|
+
const back = triggerNode();
|
|
116
|
+
closeMenu();
|
|
117
|
+
back?.focus();
|
|
118
|
+
}
|
|
26
119
|
|
|
27
120
|
function toggle() {
|
|
28
|
-
open
|
|
121
|
+
if (open) closeMenu();
|
|
122
|
+
else openMenu(first());
|
|
29
123
|
}
|
|
30
124
|
|
|
31
125
|
function handleItemClick(item: DropdownItem) {
|
|
32
126
|
if (item.disabled) return;
|
|
33
|
-
|
|
127
|
+
const back = triggerNode();
|
|
128
|
+
closeMenu();
|
|
129
|
+
back?.focus();
|
|
130
|
+
// Focus is settled before the action runs: the callback may open a dialog
|
|
131
|
+
// that moves focus itself, and it must not be moved again afterwards.
|
|
34
132
|
item.onclick();
|
|
35
133
|
}
|
|
36
134
|
|
|
37
135
|
function handleOutsideClick(e: MouseEvent) {
|
|
38
|
-
|
|
136
|
+
// No focus is returned here. The pointer has already chosen where to go.
|
|
137
|
+
if (containerEl && !containerEl.contains(e.target as Node)) closeMenu();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The last resort for Escape, for the case where focus has left the menu
|
|
142
|
+
* without closing it. The handler below consumes the key whenever focus is
|
|
143
|
+
* still inside, so the two never both fire.
|
|
144
|
+
*/
|
|
145
|
+
function handleDocumentKeydown(e: KeyboardEvent) {
|
|
146
|
+
if (e.key === 'Escape') closeAndReturn();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function onKeydown(event: KeyboardEvent) {
|
|
150
|
+
if (!open) {
|
|
151
|
+
// The menu-button pattern: either arrow opens the menu, one at the top of
|
|
152
|
+
// the list and the other at the bottom.
|
|
153
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
openMenu(event.key === 'ArrowDown' ? first() : last());
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
switch (event.key) {
|
|
160
|
+
case 'ArrowDown':
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
active = step(active, 1);
|
|
163
|
+
break;
|
|
164
|
+
|
|
165
|
+
case 'ArrowUp':
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
active = step(active, -1);
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'Home':
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
active = first();
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case 'End':
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
active = last();
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
case 'Escape':
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
// Stopped, or one press closes both this menu and the modal holding it.
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
closeAndReturn();
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'Tab':
|
|
188
|
+
// Never preventDefault: Tab is how focus leaves the menu. Focus is
|
|
189
|
+
// moved to the trigger first and synchronously, so the browser walks on
|
|
190
|
+
// from there rather than from a row it is about to lose.
|
|
191
|
+
closeAndReturn();
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
default:
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
39
197
|
}
|
|
40
198
|
|
|
41
|
-
|
|
42
|
-
|
|
199
|
+
/**
|
|
200
|
+
* The wrapper's keydown, registered as an action rather than written on the
|
|
201
|
+
* element.
|
|
202
|
+
*
|
|
203
|
+
* The keys have to be taken above the trigger, because the trigger is a
|
|
204
|
+
* snippet the caller fills and a closed menu is opened from it. Written on
|
|
205
|
+
* the element, that reads to the compiler as a plain div being made
|
|
206
|
+
* interactive without a role, which is a defect everywhere except here: the
|
|
207
|
+
* role that describes this subtree, `menu`, is on the panel inside it, and
|
|
208
|
+
* the wrapper is not the thing being operated.
|
|
209
|
+
*/
|
|
210
|
+
function keys(node: HTMLElement): { destroy(): void } {
|
|
211
|
+
node.addEventListener('keydown', onKeydown);
|
|
212
|
+
return {
|
|
213
|
+
destroy() {
|
|
214
|
+
node.removeEventListener('keydown', onKeydown);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
43
217
|
}
|
|
44
218
|
|
|
45
219
|
$effect(() => {
|
|
46
220
|
if (open) {
|
|
47
221
|
document.addEventListener('click', handleOutsideClick, { capture: true });
|
|
48
|
-
document.addEventListener('keydown',
|
|
222
|
+
document.addEventListener('keydown', handleDocumentKeydown);
|
|
49
223
|
}
|
|
50
224
|
return () => {
|
|
51
225
|
document.removeEventListener('click', handleOutsideClick, { capture: true });
|
|
52
|
-
document.removeEventListener('keydown',
|
|
226
|
+
document.removeEventListener('keydown', handleDocumentKeydown);
|
|
53
227
|
};
|
|
54
228
|
});
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* Roving focus, as real focus rather than as aria-activedescendant: the rows
|
|
232
|
+
* are buttons the user activates with Enter and Space, and a button that is
|
|
233
|
+
* described as focused without being focused does not take either key.
|
|
234
|
+
*/
|
|
235
|
+
$effect(() => {
|
|
236
|
+
if (!open) return;
|
|
237
|
+
itemNodes()[active]?.focus();
|
|
238
|
+
});
|
|
55
239
|
</script>
|
|
56
240
|
|
|
57
|
-
<div bind:this={containerEl} class="relative inline-block {cls}">
|
|
241
|
+
<div bind:this={containerEl} use:keys class="relative inline-block {cls}">
|
|
58
242
|
{@render trigger({ open, toggle })}
|
|
59
243
|
|
|
60
244
|
{#if open}
|
|
61
245
|
<div
|
|
246
|
+
bind:this={menuEl}
|
|
62
247
|
role="menu"
|
|
63
|
-
class="absolute z-
|
|
248
|
+
class="absolute z-dropdown mt-1 py-1 min-w-36 rounded-xl border border-line
|
|
64
249
|
bg-surface shadow-2xl
|
|
65
250
|
{align === 'right' ? 'right-0' : 'left-0'}"
|
|
66
251
|
>
|
|
67
|
-
{#each items as item}
|
|
252
|
+
{#each items as item, index}
|
|
68
253
|
<button
|
|
69
254
|
type="button"
|
|
70
255
|
role="menuitem"
|
|
71
256
|
disabled={item.disabled}
|
|
257
|
+
tabindex={index === active ? 0 : -1}
|
|
72
258
|
onclick={() => handleItemClick(item)}
|
|
73
259
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors duration-150
|
|
74
260
|
disabled:opacity-40 disabled:cursor-not-allowed
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A menu behind a trigger of the caller's own.
|
|
3
|
+
*
|
|
4
|
+
* The roles were the whole implementation: `menu` and `menuitem` were stated
|
|
5
|
+
* and nothing else was. A screen reader takes those roles at their word and
|
|
6
|
+
* tells the user to arrow through the items, so it announced a keyboard
|
|
7
|
+
* contract the component did not keep, and the rows were left in the tab
|
|
8
|
+
* sequence instead. That is worse than a plain list of buttons, which at
|
|
9
|
+
* least announces what it is. The contract is kept here: focus moves into the
|
|
10
|
+
* menu when it opens, the arrows and Home and End move it between the items,
|
|
11
|
+
* and Escape and Tab both close and hand focus back to the trigger.
|
|
12
|
+
*/
|
|
1
13
|
import type { Component, Snippet } from 'svelte';
|
|
2
14
|
interface DropdownItem {
|
|
3
15
|
label: string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The product mark, and the wordmark beside it.
|
|
4
|
+
*
|
|
5
|
+
* Two applications each carried their own copy of this lockup and the two had
|
|
6
|
+
* drifted: different box sizes, different wordmark weights, and one of them
|
|
7
|
+
* painting the mark from hex behind a prefers-color-scheme query. That query
|
|
8
|
+
* asks the operating system, while the apps theme off `data-theme` on the
|
|
9
|
+
* html element, so the two disagreed for anyone who chose a theme their
|
|
10
|
+
* desktop was not already set to: a light desktop with the app in dark mode
|
|
11
|
+
* drew a near-black column on a dark surface. There is no media query here
|
|
12
|
+
* for that reason, and none belongs here.
|
|
13
|
+
*
|
|
14
|
+
* The neutral faces come from currentColor and the lit faces from the brand
|
|
15
|
+
* ramp, so every face answers the same tokens as the rest of the page and a
|
|
16
|
+
* palette change reaches the mark with everything else.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Size = 'sm' | 'md' | 'lg';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/**
|
|
23
|
+
* Named rather than a pixel count. The two copies this replaces shipped at
|
|
24
|
+
* 28px and 36px with different wordmark weights, and a free number is how
|
|
25
|
+
* the third one drifts too.
|
|
26
|
+
*/
|
|
27
|
+
size?: Size;
|
|
28
|
+
/**
|
|
29
|
+
* False renders the mark alone, for a slot too narrow to carry the name.
|
|
30
|
+
* The mark is decoration and is hidden from a screen reader either way, so
|
|
31
|
+
* a link holding only the mark has to carry its own name.
|
|
32
|
+
*/
|
|
33
|
+
wordmark?: boolean;
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let { size = 'md', wordmark = true, class: klass = '' }: Props = $props();
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* The box is a width and a height attribute, not a utility class: the two
|
|
41
|
+
* dimensions are a runtime value, and a class assembled from one matches no
|
|
42
|
+
* candidate in Tailwind's scan and silently does nothing.
|
|
43
|
+
*/
|
|
44
|
+
const marks: Record<Size, number> = { sm: 24, md: 28, lg: 36 };
|
|
45
|
+
const words: Record<Size, string> = { sm: 'text-sm', md: 'text-base', lg: 'text-xl' };
|
|
46
|
+
|
|
47
|
+
const px = $derived(marks[size]);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<!-- text-fg is stated here rather than inherited. The mark is usually wrapped
|
|
51
|
+
in a link that carries no colour of its own, so currentColor would
|
|
52
|
+
otherwise be whatever that ancestor happened to hold. -->
|
|
53
|
+
<span class="inline-flex items-center gap-2 text-fg {klass}">
|
|
54
|
+
<span class="inline-flex shrink-0 items-center justify-center">
|
|
55
|
+
<svg
|
|
56
|
+
width={px}
|
|
57
|
+
height={px}
|
|
58
|
+
viewBox="0 0 72 64"
|
|
59
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<g transform="translate(6 4)">
|
|
63
|
+
<polygon class="fill-current" points="0,14 14,7 14,40 0,47" />
|
|
64
|
+
<polygon class="fill-current opacity-50" points="0,47 14,40 14,54 0,61" />
|
|
65
|
+
<polygon class="fill-current opacity-75" points="14,40 28,33 28,47 14,54" />
|
|
66
|
+
<polygon class="fill-brand-light" points="14,54 28,47 56,33 42,40" />
|
|
67
|
+
<polygon class="fill-brand-deep" points="42,40 56,33 56,47 42,54" />
|
|
68
|
+
<polygon class="fill-brand" points="14,54 28,61 42,54 28,47" />
|
|
69
|
+
<polygon class="fill-brand" points="28,47 42,54 56,47 42,40" />
|
|
70
|
+
</g>
|
|
71
|
+
</svg>
|
|
72
|
+
</span>
|
|
73
|
+
|
|
74
|
+
{#if wordmark}
|
|
75
|
+
<span class="font-semibold tracking-tight {words[size]}">LyEve</span>
|
|
76
|
+
{/if}
|
|
77
|
+
</span>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product mark, and the wordmark beside it.
|
|
3
|
+
*
|
|
4
|
+
* Two applications each carried their own copy of this lockup and the two had
|
|
5
|
+
* drifted: different box sizes, different wordmark weights, and one of them
|
|
6
|
+
* painting the mark from hex behind a prefers-color-scheme query. That query
|
|
7
|
+
* asks the operating system, while the apps theme off `data-theme` on the
|
|
8
|
+
* html element, so the two disagreed for anyone who chose a theme their
|
|
9
|
+
* desktop was not already set to: a light desktop with the app in dark mode
|
|
10
|
+
* drew a near-black column on a dark surface. There is no media query here
|
|
11
|
+
* for that reason, and none belongs here.
|
|
12
|
+
*
|
|
13
|
+
* The neutral faces come from currentColor and the lit faces from the brand
|
|
14
|
+
* ramp, so every face answers the same tokens as the rest of the page and a
|
|
15
|
+
* palette change reaches the mark with everything else.
|
|
16
|
+
*/
|
|
17
|
+
type Size = 'sm' | 'md' | 'lg';
|
|
18
|
+
interface Props {
|
|
19
|
+
/**
|
|
20
|
+
* Named rather than a pixel count. The two copies this replaces shipped at
|
|
21
|
+
* 28px and 36px with different wordmark weights, and a free number is how
|
|
22
|
+
* the third one drifts too.
|
|
23
|
+
*/
|
|
24
|
+
size?: Size;
|
|
25
|
+
/**
|
|
26
|
+
* False renders the mark alone, for a slot too narrow to carry the name.
|
|
27
|
+
* The mark is decoration and is hidden from a screen reader either way, so
|
|
28
|
+
* a link holding only the mark has to carry its own name.
|
|
29
|
+
*/
|
|
30
|
+
wordmark?: boolean;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
declare const Logo: import("svelte").Component<Props, {}, "">;
|
|
34
|
+
type Logo = ReturnType<typeof Logo>;
|
|
35
|
+
export default Logo;
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
<svelte:window onkeydown={open ? onkeydown : undefined} />
|
|
52
52
|
|
|
53
53
|
{#if open}
|
|
54
|
-
<div class="fixed inset-0 z-
|
|
54
|
+
<div class="fixed inset-0 z-modal flex items-center justify-center p-4">
|
|
55
55
|
<button
|
|
56
56
|
type="button"
|
|
57
57
|
tabindex="-1"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
use:overlay
|
|
65
65
|
class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
|
|
66
66
|
overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
|
|
67
|
-
animate-[modal-
|
|
67
|
+
animate-[modal-in_var(--duration-modal-in)_ease-out]"
|
|
68
68
|
role="dialog"
|
|
69
69
|
aria-modal="true"
|
|
70
70
|
aria-labelledby={title ? headingId : undefined}
|
|
@@ -114,16 +114,3 @@
|
|
|
114
114
|
</div>
|
|
115
115
|
</div>
|
|
116
116
|
{/if}
|
|
117
|
-
|
|
118
|
-
<style>
|
|
119
|
-
@keyframes modal-in {
|
|
120
|
-
from {
|
|
121
|
-
opacity: 0;
|
|
122
|
-
transform: translateY(6px) scale(0.98);
|
|
123
|
-
}
|
|
124
|
-
to {
|
|
125
|
-
opacity: 1;
|
|
126
|
-
transform: translateY(0) scale(1);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
</style>
|