@noahbaumgartner/svelte-ui 0.0.4 → 0.0.5
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 +8 -8
- package/dist/components/accordion/Accordion.svelte +34 -0
- package/dist/components/accordion/Accordion.svelte.d.ts +11 -0
- package/dist/components/accordion/AccordionItem.svelte +106 -0
- package/dist/components/accordion/AccordionItem.svelte.d.ts +13 -0
- package/dist/components/accordion/context.d.ts +5 -0
- package/dist/components/accordion/context.js +2 -0
- package/dist/components/avatar-group/AvatarGroup.svelte +76 -0
- package/dist/components/avatar-group/AvatarGroup.svelte.d.ts +15 -0
- package/dist/components/drawer/Drawer.svelte +368 -0
- package/dist/components/drawer/Drawer.svelte.d.ts +23 -0
- package/dist/components/empty-state/EmptyState.svelte +86 -0
- package/dist/components/empty-state/EmptyState.svelte.d.ts +16 -0
- package/dist/components/entry/Entry.svelte +83 -0
- package/dist/components/entry/Entry.svelte.d.ts +15 -0
- package/dist/components/footer/Footer.svelte +89 -0
- package/dist/components/footer/Footer.svelte.d.ts +13 -0
- package/dist/components/footer/FooterColumn.svelte +34 -0
- package/dist/components/footer/FooterColumn.svelte.d.ts +10 -0
- package/dist/components/mesh-gradient/MeshGradient.svelte +86 -0
- package/dist/components/mesh-gradient/MeshGradient.svelte.d.ts +12 -0
- package/dist/components/number-stepper/NumberStepper.svelte +82 -0
- package/dist/components/number-stepper/NumberStepper.svelte.d.ts +16 -0
- package/dist/components/preview-card/PreviewCard.svelte +166 -0
- package/dist/components/preview-card/PreviewCard.svelte.d.ts +20 -0
- package/dist/components/progress/Progress.svelte +88 -0
- package/dist/components/progress/Progress.svelte.d.ts +12 -0
- package/dist/components/select/Select.svelte +391 -0
- package/dist/components/select/Select.svelte.d.ts +39 -0
- package/dist/components/sidebar/Sidebar.svelte +37 -0
- package/dist/components/sidebar/SidebarHeader.svelte +26 -2
- package/dist/components/tabs/Tabs.svelte +280 -0
- package/dist/components/tabs/Tabs.svelte.d.ts +48 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { LucideIcon } from '@lucide/svelte';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** Makes the whole card a link. */
|
|
7
|
+
href?: string;
|
|
8
|
+
/** Bold lead-in before the description, followed by a colon. */
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Muted text below, e.g. a date. */
|
|
12
|
+
meta?: string;
|
|
13
|
+
/** Any Lucide icon (import from `svelte-ui/icons`), shown in the top-left corner of the media. */
|
|
14
|
+
icon?: LucideIcon;
|
|
15
|
+
orientation?: 'vertical' | 'horizontal';
|
|
16
|
+
/** Square media that fills the frame, e.g. a MeshGradient or an `<img>`. */
|
|
17
|
+
media: Snippet;
|
|
18
|
+
class?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
href,
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
meta,
|
|
26
|
+
icon: Icon,
|
|
27
|
+
orientation = 'vertical',
|
|
28
|
+
media,
|
|
29
|
+
class: className
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<svelte:element
|
|
34
|
+
this={href ? 'a' : 'div'}
|
|
35
|
+
{href}
|
|
36
|
+
class={['preview-card', `preview-card--${orientation}`, className]}
|
|
37
|
+
>
|
|
38
|
+
<div class="preview-card-media">
|
|
39
|
+
{@render media()}
|
|
40
|
+
{#if Icon}
|
|
41
|
+
<span class="preview-card-icon"><Icon aria-hidden="true" /></span>
|
|
42
|
+
{/if}
|
|
43
|
+
</div>
|
|
44
|
+
<div class="preview-card-info">
|
|
45
|
+
<p class="preview-card-headline">
|
|
46
|
+
<span class="preview-card-title">{title}{description ? ':' : ''}</span>
|
|
47
|
+
{description}
|
|
48
|
+
</p>
|
|
49
|
+
{#if meta}
|
|
50
|
+
<span class="preview-card-meta">{meta}</span>
|
|
51
|
+
{/if}
|
|
52
|
+
</div>
|
|
53
|
+
</svelte:element>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.preview-card {
|
|
57
|
+
display: flex;
|
|
58
|
+
gap: 20px;
|
|
59
|
+
color: var(--color-text);
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
border-radius: 14px;
|
|
62
|
+
outline: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
a.preview-card:focus-visible {
|
|
66
|
+
outline: 2px solid var(--color-accent);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.preview-card--vertical {
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.preview-card--horizontal {
|
|
75
|
+
align-items: center;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.preview-card-media {
|
|
79
|
+
position: relative;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
aspect-ratio: 1;
|
|
82
|
+
overflow: hidden;
|
|
83
|
+
border-radius: 14px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.preview-card--horizontal .preview-card-media {
|
|
87
|
+
width: 140px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.preview-card-media > :global(:not(.preview-card-icon)) {
|
|
91
|
+
position: absolute;
|
|
92
|
+
inset: 0;
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
object-fit: cover;
|
|
96
|
+
border-radius: inherit;
|
|
97
|
+
transition: transform 300ms ease;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
a.preview-card:hover .preview-card-media > :global(:not(.preview-card-icon)) {
|
|
101
|
+
transform: scale(1.05);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.preview-card-icon {
|
|
105
|
+
position: absolute;
|
|
106
|
+
top: 0;
|
|
107
|
+
left: 0;
|
|
108
|
+
z-index: 1;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
112
|
+
width: 40px;
|
|
113
|
+
height: 40px;
|
|
114
|
+
color: var(--color-base);
|
|
115
|
+
background-color: var(--color-ink);
|
|
116
|
+
border-radius: 14px 0 0 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.preview-card-icon :global(svg) {
|
|
120
|
+
width: 20px;
|
|
121
|
+
height: 20px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.preview-card--horizontal .preview-card-icon {
|
|
125
|
+
width: 32px;
|
|
126
|
+
height: 32px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.preview-card--horizontal .preview-card-icon :global(svg) {
|
|
130
|
+
width: 16px;
|
|
131
|
+
height: 16px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.preview-card-info {
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: 8px;
|
|
138
|
+
min-width: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.preview-card-headline {
|
|
142
|
+
margin: 0;
|
|
143
|
+
font-size: 16px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.preview-card-title {
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.preview-card-meta {
|
|
151
|
+
font-size: 14px;
|
|
152
|
+
color: var(--color-text-muted);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@media (min-width: 640px) {
|
|
156
|
+
.preview-card--horizontal .preview-card-media {
|
|
157
|
+
width: 168px;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@media (prefers-reduced-motion: reduce) {
|
|
162
|
+
.preview-card-media > :global(*) {
|
|
163
|
+
transition: none;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { LucideIcon } from '@lucide/svelte';
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Makes the whole card a link. */
|
|
5
|
+
href?: string;
|
|
6
|
+
/** Bold lead-in before the description, followed by a colon. */
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Muted text below, e.g. a date. */
|
|
10
|
+
meta?: string;
|
|
11
|
+
/** Any Lucide icon (import from `svelte-ui/icons`), shown in the top-left corner of the media. */
|
|
12
|
+
icon?: LucideIcon;
|
|
13
|
+
orientation?: 'vertical' | 'horizontal';
|
|
14
|
+
/** Square media that fills the frame, e.g. a MeshGradient or an `<img>`. */
|
|
15
|
+
media: Snippet;
|
|
16
|
+
class?: string;
|
|
17
|
+
};
|
|
18
|
+
declare const PreviewCard: import("svelte").Component<Props, {}, "">;
|
|
19
|
+
type PreviewCard = ReturnType<typeof PreviewCard>;
|
|
20
|
+
export default PreviewCard;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
5
|
+
/** Progress between 0 and `max`. Omit while the amount is unknown: the bar then runs back and forth. */
|
|
6
|
+
value?: number;
|
|
7
|
+
max?: number;
|
|
8
|
+
size?: 'sm' | 'md';
|
|
9
|
+
/** Accessible name, e.g. what is loading. */
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let { value, max = 100, size = 'md', label, class: className, ...rest }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let fraction = $derived(value === undefined ? undefined : Math.min(Math.max(value / max, 0), 1));
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<div
|
|
19
|
+
{...rest}
|
|
20
|
+
role="progressbar"
|
|
21
|
+
aria-label={label}
|
|
22
|
+
aria-valuemin={0}
|
|
23
|
+
aria-valuemax={max}
|
|
24
|
+
aria-valuenow={fraction === undefined ? undefined : fraction * max}
|
|
25
|
+
class={[
|
|
26
|
+
'progress',
|
|
27
|
+
`progress--${size}`,
|
|
28
|
+
{ 'progress--indeterminate': fraction === undefined },
|
|
29
|
+
className
|
|
30
|
+
]}
|
|
31
|
+
>
|
|
32
|
+
<div
|
|
33
|
+
class="progress-bar"
|
|
34
|
+
style:width={fraction === undefined ? undefined : `${fraction * 100}%`}
|
|
35
|
+
></div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.progress {
|
|
40
|
+
width: 100%;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
background-color: var(--color-surface);
|
|
43
|
+
border-radius: 999px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.progress--sm {
|
|
47
|
+
height: 4px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.progress--md {
|
|
51
|
+
height: 8px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.progress-bar {
|
|
55
|
+
height: 100%;
|
|
56
|
+
background-color: var(--color-accent);
|
|
57
|
+
border-radius: inherit;
|
|
58
|
+
transition: width 300ms ease;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.progress--indeterminate .progress-bar {
|
|
62
|
+
width: 40%;
|
|
63
|
+
animation: progress-slide 1.4s ease-in-out infinite;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@keyframes progress-slide {
|
|
67
|
+
from {
|
|
68
|
+
translate: -100% 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
to {
|
|
72
|
+
translate: 250% 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@media (prefers-reduced-motion: reduce) {
|
|
77
|
+
.progress-bar {
|
|
78
|
+
transition: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* A full, dimmed bar instead of the movement */
|
|
82
|
+
.progress--indeterminate .progress-bar {
|
|
83
|
+
width: 100%;
|
|
84
|
+
opacity: 0.4;
|
|
85
|
+
animation: none;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
3
|
+
/** Progress between 0 and `max`. Omit while the amount is unknown: the bar then runs back and forth. */
|
|
4
|
+
value?: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
size?: 'sm' | 'md';
|
|
7
|
+
/** Accessible name, e.g. what is loading. */
|
|
8
|
+
label?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const Progress: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type Progress = ReturnType<typeof Progress>;
|
|
12
|
+
export default Progress;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type SelectOption<T extends string | number | null = string> = {
|
|
3
|
+
value: T;
|
|
4
|
+
label: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
};
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script lang="ts" generics="T extends string | number | null">
|
|
10
|
+
import { tick } from 'svelte';
|
|
11
|
+
import { scale } from 'svelte/transition';
|
|
12
|
+
import type { Attachment } from 'svelte/attachments';
|
|
13
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
14
|
+
import { Check, ChevronsUpDown } from '@lucide/svelte';
|
|
15
|
+
|
|
16
|
+
type Props = Omit<
|
|
17
|
+
HTMLButtonAttributes,
|
|
18
|
+
'value' | 'children' | 'onchange' | 'type' | 'role' | 'name'
|
|
19
|
+
> & {
|
|
20
|
+
/** Value of the chosen option. Bindable. */
|
|
21
|
+
value?: T;
|
|
22
|
+
options: SelectOption<T>[];
|
|
23
|
+
/** Shown while no option is chosen. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Form field name; submits the value through a hidden input. */
|
|
26
|
+
name?: string;
|
|
27
|
+
onchange?: (value: T) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
value = $bindable(),
|
|
32
|
+
options,
|
|
33
|
+
placeholder = 'Select',
|
|
34
|
+
name,
|
|
35
|
+
disabled = false,
|
|
36
|
+
class: className,
|
|
37
|
+
onchange,
|
|
38
|
+
...rest
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const id = $props.id();
|
|
42
|
+
const gap = 4;
|
|
43
|
+
const edge = 8;
|
|
44
|
+
const maxHeight = 320;
|
|
45
|
+
|
|
46
|
+
let open = $state(false);
|
|
47
|
+
let wrapperEl: HTMLSpanElement;
|
|
48
|
+
let triggerEl: HTMLButtonElement;
|
|
49
|
+
let listEl: HTMLDivElement | undefined = $state();
|
|
50
|
+
let placement: 'top' | 'bottom' = $state('bottom');
|
|
51
|
+
let position: { top?: number; bottom?: number; left: number; width: number; height: number } =
|
|
52
|
+
$state({ left: 0, width: 0, height: maxHeight });
|
|
53
|
+
|
|
54
|
+
let current = $derived(options.find((option) => option.value === value));
|
|
55
|
+
|
|
56
|
+
function items() {
|
|
57
|
+
return Array.from(
|
|
58
|
+
listEl?.querySelectorAll<HTMLElement>('[role="option"]:not(:disabled)') ?? []
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function show(focus: 'first' | 'last' = 'first') {
|
|
63
|
+
open = true;
|
|
64
|
+
await tick();
|
|
65
|
+
const list = items();
|
|
66
|
+
const selected = list.find((item) => item.getAttribute('aria-selected') === 'true');
|
|
67
|
+
(selected ?? (focus === 'first' ? list[0] : list.at(-1)))?.focus();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function close({ restoreFocus = false } = {}) {
|
|
71
|
+
open = false;
|
|
72
|
+
if (restoreFocus) triggerEl.focus();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function choose(option: SelectOption<T>) {
|
|
76
|
+
value = option.value;
|
|
77
|
+
onchange?.(option.value);
|
|
78
|
+
close({ restoreFocus: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The list sits in the top layer so dialogs and scroll containers cannot clip it */
|
|
82
|
+
function place() {
|
|
83
|
+
if (!listEl) return;
|
|
84
|
+
const rect = triggerEl.getBoundingClientRect();
|
|
85
|
+
const below = window.innerHeight - rect.bottom - gap - edge;
|
|
86
|
+
const above = rect.top - gap - edge;
|
|
87
|
+
const wanted = Math.min(listEl.scrollHeight + 2, maxHeight);
|
|
88
|
+
placement = below < wanted && above > below ? 'top' : 'bottom';
|
|
89
|
+
const left = Math.max(edge, Math.min(rect.left, window.innerWidth - listEl.offsetWidth - edge));
|
|
90
|
+
position =
|
|
91
|
+
placement === 'top'
|
|
92
|
+
? {
|
|
93
|
+
bottom: window.innerHeight - rect.top + gap,
|
|
94
|
+
left,
|
|
95
|
+
width: rect.width,
|
|
96
|
+
height: Math.min(maxHeight, above)
|
|
97
|
+
}
|
|
98
|
+
: { top: rect.bottom + gap, left, width: rect.width, height: Math.min(maxHeight, below) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const popover: Attachment<HTMLDivElement> = (node) => {
|
|
102
|
+
node.showPopover();
|
|
103
|
+
place();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/* Typing jumps to the option that starts with the typed letters */
|
|
107
|
+
let typed = '';
|
|
108
|
+
let typedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
|
|
110
|
+
function typeahead(key: string) {
|
|
111
|
+
clearTimeout(typedTimer);
|
|
112
|
+
typed += key.toLowerCase();
|
|
113
|
+
typedTimer = setTimeout(() => (typed = ''), 500);
|
|
114
|
+
items()
|
|
115
|
+
.find((item) => item.textContent?.trim().toLowerCase().startsWith(typed))
|
|
116
|
+
?.focus();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleListKeydown(event: KeyboardEvent) {
|
|
120
|
+
if (event.key === 'Escape') {
|
|
121
|
+
// Only the list closes, not a Dialog or Drawer around it
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
event.stopPropagation();
|
|
124
|
+
close({ restoreFocus: true });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (event.key === 'Tab') {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
close({ restoreFocus: true });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const list = items();
|
|
134
|
+
const index = list.indexOf(document.activeElement as HTMLElement);
|
|
135
|
+
const last = list.length - 1;
|
|
136
|
+
const target = {
|
|
137
|
+
ArrowDown: index === last ? 0 : index + 1,
|
|
138
|
+
ArrowUp: index <= 0 ? last : index - 1,
|
|
139
|
+
Home: 0,
|
|
140
|
+
End: last
|
|
141
|
+
}[event.key];
|
|
142
|
+
|
|
143
|
+
if (target !== undefined) {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
list[target]?.focus();
|
|
146
|
+
} else if (event.key.length === 1 && event.key !== ' ' && !event.metaKey && !event.ctrlKey) {
|
|
147
|
+
typeahead(event.key);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
$effect(() => {
|
|
152
|
+
if (!open) return;
|
|
153
|
+
|
|
154
|
+
function handlePointerDown(event: PointerEvent) {
|
|
155
|
+
if (!wrapperEl.contains(event.target as Node)) close();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
window.addEventListener('pointerdown', handlePointerDown);
|
|
159
|
+
window.addEventListener('scroll', place, true);
|
|
160
|
+
window.addEventListener('resize', place);
|
|
161
|
+
|
|
162
|
+
return () => {
|
|
163
|
+
window.removeEventListener('pointerdown', handlePointerDown);
|
|
164
|
+
window.removeEventListener('scroll', place, true);
|
|
165
|
+
window.removeEventListener('resize', place);
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<span class={['select', className]} bind:this={wrapperEl}>
|
|
171
|
+
<button
|
|
172
|
+
{...rest}
|
|
173
|
+
bind:this={triggerEl}
|
|
174
|
+
type="button"
|
|
175
|
+
role="combobox"
|
|
176
|
+
aria-haspopup="listbox"
|
|
177
|
+
aria-expanded={open}
|
|
178
|
+
aria-controls="{id}-listbox"
|
|
179
|
+
{disabled}
|
|
180
|
+
class="select-trigger"
|
|
181
|
+
onclick={(event) => {
|
|
182
|
+
rest.onclick?.(event);
|
|
183
|
+
if (open) close();
|
|
184
|
+
else show();
|
|
185
|
+
}}
|
|
186
|
+
onkeydown={(event) => {
|
|
187
|
+
rest.onkeydown?.(event);
|
|
188
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
show(event.key === 'ArrowDown' ? 'first' : 'last');
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<span class={['select-value', { 'select-value--placeholder': !current }]}>
|
|
194
|
+
{current?.label ?? placeholder}
|
|
195
|
+
</span>
|
|
196
|
+
<ChevronsUpDown class="select-chevron" aria-hidden="true" />
|
|
197
|
+
</button>
|
|
198
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
199
|
+
|
|
200
|
+
{#if open}
|
|
201
|
+
<div
|
|
202
|
+
id="{id}-listbox"
|
|
203
|
+
role="listbox"
|
|
204
|
+
tabindex="-1"
|
|
205
|
+
popover="manual"
|
|
206
|
+
class={['select-list', `select-list--${placement}`]}
|
|
207
|
+
style:top={position.top === undefined ? undefined : `${position.top}px`}
|
|
208
|
+
style:bottom={position.bottom === undefined ? undefined : `${position.bottom}px`}
|
|
209
|
+
style:left="{position.left}px"
|
|
210
|
+
style:min-width="{position.width}px"
|
|
211
|
+
style:max-height="{position.height}px"
|
|
212
|
+
bind:this={listEl}
|
|
213
|
+
onkeydown={handleListKeydown}
|
|
214
|
+
onfocusout={(event) => {
|
|
215
|
+
if (!wrapperEl.contains(event.relatedTarget as Node | null)) close();
|
|
216
|
+
}}
|
|
217
|
+
{@attach popover}
|
|
218
|
+
transition:scale={{ duration: 140, start: 0.95 }}
|
|
219
|
+
>
|
|
220
|
+
{#each options as option (option.value)}
|
|
221
|
+
{@const selected = option.value === value}
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
role="option"
|
|
225
|
+
tabindex="-1"
|
|
226
|
+
aria-selected={selected}
|
|
227
|
+
disabled={option.disabled}
|
|
228
|
+
class={['select-option', { 'select-option--selected': selected }]}
|
|
229
|
+
onclick={() => choose(option)}
|
|
230
|
+
>
|
|
231
|
+
<span class="select-option-label">{option.label}</span>
|
|
232
|
+
<span class="select-option-check">
|
|
233
|
+
{#if selected}<Check aria-hidden="true" />{/if}
|
|
234
|
+
</span>
|
|
235
|
+
</button>
|
|
236
|
+
{/each}
|
|
237
|
+
</div>
|
|
238
|
+
{/if}
|
|
239
|
+
</span>
|
|
240
|
+
|
|
241
|
+
<style>
|
|
242
|
+
.select {
|
|
243
|
+
display: flex;
|
|
244
|
+
width: 100%;
|
|
245
|
+
min-width: 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* Looks like an Input */
|
|
249
|
+
.select-trigger {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
justify-content: space-between;
|
|
253
|
+
gap: 8px;
|
|
254
|
+
box-sizing: border-box;
|
|
255
|
+
width: 100%;
|
|
256
|
+
min-width: 0;
|
|
257
|
+
height: 32px;
|
|
258
|
+
padding: 0 8px 0 10px;
|
|
259
|
+
font-family: inherit;
|
|
260
|
+
font-size: 13px;
|
|
261
|
+
color: var(--color-text);
|
|
262
|
+
text-align: left;
|
|
263
|
+
background-color: transparent;
|
|
264
|
+
border: 1px solid var(--color-border-strong);
|
|
265
|
+
border-radius: 10px;
|
|
266
|
+
outline: none;
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
transition: border-color 200ms ease;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.select-trigger:focus-visible,
|
|
272
|
+
.select-trigger[aria-expanded='true'] {
|
|
273
|
+
border-color: var(--color-accent);
|
|
274
|
+
outline: 1px solid var(--color-accent);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.select-trigger[aria-invalid='true'] {
|
|
278
|
+
border-color: var(--color-destructive);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.select-trigger[aria-invalid='true']:is(:focus-visible, [aria-expanded='true']) {
|
|
282
|
+
outline-color: var(--color-destructive);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.select-trigger:disabled {
|
|
286
|
+
cursor: not-allowed;
|
|
287
|
+
opacity: 0.5;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.select-value {
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
text-overflow: ellipsis;
|
|
293
|
+
white-space: nowrap;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.select-value--placeholder {
|
|
297
|
+
color: var(--color-text-faint);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.select-trigger :global(.select-chevron) {
|
|
301
|
+
width: 14px;
|
|
302
|
+
height: 14px;
|
|
303
|
+
flex-shrink: 0;
|
|
304
|
+
color: var(--color-text-muted);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.select-list {
|
|
308
|
+
position: fixed;
|
|
309
|
+
inset: auto;
|
|
310
|
+
flex-direction: column;
|
|
311
|
+
gap: 2px;
|
|
312
|
+
box-sizing: border-box;
|
|
313
|
+
max-width: calc(100vw - 16px);
|
|
314
|
+
margin: 0;
|
|
315
|
+
padding: 4px;
|
|
316
|
+
overflow-y: auto;
|
|
317
|
+
overscroll-behavior: contain;
|
|
318
|
+
background-color: var(--color-bg);
|
|
319
|
+
color: var(--color-text);
|
|
320
|
+
border: 1px solid var(--color-border);
|
|
321
|
+
border-radius: 10px;
|
|
322
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
323
|
+
outline: none;
|
|
324
|
+
user-select: none;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.select-list:popover-open {
|
|
328
|
+
display: flex;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.select-list--bottom {
|
|
332
|
+
transform-origin: top left;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.select-list--top {
|
|
336
|
+
transform-origin: bottom left;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.select-option {
|
|
340
|
+
display: flex;
|
|
341
|
+
flex-shrink: 0;
|
|
342
|
+
align-items: center;
|
|
343
|
+
gap: 10px;
|
|
344
|
+
width: 100%;
|
|
345
|
+
padding: 8px 10px;
|
|
346
|
+
font: inherit;
|
|
347
|
+
font-size: 13px;
|
|
348
|
+
color: var(--color-text);
|
|
349
|
+
text-align: left;
|
|
350
|
+
background-color: transparent;
|
|
351
|
+
border: none;
|
|
352
|
+
border-radius: 6px;
|
|
353
|
+
outline: none;
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
transition: background-color 150ms ease;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Plain :focus, so the current option also shows after opening with the mouse */
|
|
359
|
+
.select-option:hover:not(:disabled),
|
|
360
|
+
.select-option:focus {
|
|
361
|
+
background-color: var(--color-surface);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.select-option:disabled {
|
|
365
|
+
cursor: not-allowed;
|
|
366
|
+
opacity: 0.5;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.select-option--selected {
|
|
370
|
+
font-weight: 600;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.select-option-label {
|
|
374
|
+
flex-grow: 1;
|
|
375
|
+
overflow: hidden;
|
|
376
|
+
text-overflow: ellipsis;
|
|
377
|
+
white-space: nowrap;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.select-option-check {
|
|
381
|
+
display: flex;
|
|
382
|
+
width: 14px;
|
|
383
|
+
height: 14px;
|
|
384
|
+
flex-shrink: 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.select-option-check :global(svg) {
|
|
388
|
+
width: 14px;
|
|
389
|
+
height: 14px;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type SelectOption<T extends string | number | null = string> = {
|
|
2
|
+
value: T;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
};
|
|
6
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
7
|
+
declare function $$render<T extends string | number | null>(): {
|
|
8
|
+
props: Omit<HTMLButtonAttributes, "children" | "role" | "onchange" | "name" | "type" | "value"> & {
|
|
9
|
+
/** Value of the chosen option. Bindable. */
|
|
10
|
+
value?: T;
|
|
11
|
+
options: SelectOption<T>[];
|
|
12
|
+
/** Shown while no option is chosen. */
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Form field name; submits the value through a hidden input. */
|
|
15
|
+
name?: string;
|
|
16
|
+
onchange?: (value: T) => void;
|
|
17
|
+
};
|
|
18
|
+
exports: {};
|
|
19
|
+
bindings: "value";
|
|
20
|
+
slots: {};
|
|
21
|
+
events: {};
|
|
22
|
+
};
|
|
23
|
+
declare class __sveltets_Render<T extends string | number | null> {
|
|
24
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
25
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
26
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
27
|
+
bindings(): "value";
|
|
28
|
+
exports(): {};
|
|
29
|
+
}
|
|
30
|
+
interface $$IsomorphicComponent {
|
|
31
|
+
new <T extends string | number | null>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
32
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
33
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
34
|
+
<T extends string | number | null>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
35
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
36
|
+
}
|
|
37
|
+
declare const Select: $$IsomorphicComponent;
|
|
38
|
+
type Select<T extends string | number | null> = InstanceType<typeof Select<T>>;
|
|
39
|
+
export default Select;
|