@noahbaumgartner/svelte-ui 0.0.3 → 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.
Files changed (36) hide show
  1. package/README.md +69 -38
  2. package/dist/components/accordion/Accordion.svelte +34 -0
  3. package/dist/components/accordion/Accordion.svelte.d.ts +11 -0
  4. package/dist/components/accordion/AccordionItem.svelte +106 -0
  5. package/dist/components/accordion/AccordionItem.svelte.d.ts +13 -0
  6. package/dist/components/accordion/context.d.ts +5 -0
  7. package/dist/components/accordion/context.js +2 -0
  8. package/dist/components/avatar-group/AvatarGroup.svelte +76 -0
  9. package/dist/components/avatar-group/AvatarGroup.svelte.d.ts +15 -0
  10. package/dist/components/drawer/Drawer.svelte +368 -0
  11. package/dist/components/drawer/Drawer.svelte.d.ts +23 -0
  12. package/dist/components/empty-state/EmptyState.svelte +86 -0
  13. package/dist/components/empty-state/EmptyState.svelte.d.ts +16 -0
  14. package/dist/components/entry/Entry.svelte +83 -0
  15. package/dist/components/entry/Entry.svelte.d.ts +15 -0
  16. package/dist/components/footer/Footer.svelte +89 -0
  17. package/dist/components/footer/Footer.svelte.d.ts +13 -0
  18. package/dist/components/footer/FooterColumn.svelte +34 -0
  19. package/dist/components/footer/FooterColumn.svelte.d.ts +10 -0
  20. package/dist/components/mesh-gradient/MeshGradient.svelte +86 -0
  21. package/dist/components/mesh-gradient/MeshGradient.svelte.d.ts +12 -0
  22. package/dist/components/number-stepper/NumberStepper.svelte +82 -0
  23. package/dist/components/number-stepper/NumberStepper.svelte.d.ts +16 -0
  24. package/dist/components/preview-card/PreviewCard.svelte +166 -0
  25. package/dist/components/preview-card/PreviewCard.svelte.d.ts +20 -0
  26. package/dist/components/progress/Progress.svelte +88 -0
  27. package/dist/components/progress/Progress.svelte.d.ts +12 -0
  28. package/dist/components/select/Select.svelte +391 -0
  29. package/dist/components/select/Select.svelte.d.ts +39 -0
  30. package/dist/components/sidebar/Sidebar.svelte +37 -0
  31. package/dist/components/sidebar/SidebarHeader.svelte +26 -2
  32. package/dist/components/tabs/Tabs.svelte +280 -0
  33. package/dist/components/tabs/Tabs.svelte.d.ts +48 -0
  34. package/dist/index.d.ts +15 -0
  35. package/dist/index.js +14 -0
  36. package/package.json +1 -1
@@ -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;
@@ -2,6 +2,8 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { fade } from 'svelte/transition';
5
+ import X from '@lucide/svelte/icons/x';
6
+ import Button from '../button/Button.svelte';
5
7
  import { getSidebarContext } from './context.js';
6
8
 
7
9
  type Props = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
@@ -70,6 +72,17 @@
70
72
  ]}
71
73
  >
72
74
  <div class="sidebar-inner">
75
+ {#if sidebar.mobile}
76
+ <div class="sidebar-close">
77
+ <Button
78
+ variant="ghost"
79
+ size="sm"
80
+ icon={X}
81
+ label="Close sidebar"
82
+ onclick={sidebar.closeMobile}
83
+ />
84
+ </div>
85
+ {/if}
73
86
  {@render children()}
74
87
  </div>
75
88
  </aside>
@@ -154,11 +167,24 @@
154
167
  bottom: 0;
155
168
  left: 0;
156
169
  z-index: 50;
170
+ width: 100%;
157
171
  height: auto;
158
172
  transform: translateX(-100%);
159
173
  transition: transform 200ms ease;
160
174
  }
161
175
 
176
+ .sidebar--mobile .sidebar-inner {
177
+ width: 100%;
178
+ }
179
+
180
+ .sidebar--mobile.sidebar--sidebar .sidebar-inner {
181
+ border-inline: none;
182
+ }
183
+
184
+ .sidebar--mobile.sidebar--floating .sidebar-inner {
185
+ width: calc(100% - 16px);
186
+ }
187
+
162
188
  .sidebar--mobile.sidebar--right {
163
189
  left: auto;
164
190
  right: 0;
@@ -169,6 +195,17 @@
169
195
  transform: none;
170
196
  }
171
197
 
198
+ /* Fallback for sidebars without a `SidebarHeader`, which otherwise holds the close button */
199
+ .sidebar-close {
200
+ display: flex;
201
+ justify-content: flex-end;
202
+ padding: 8px 8px 0;
203
+ }
204
+
205
+ .sidebar-inner:has(:global(.sidebar-header)) .sidebar-close {
206
+ display: none;
207
+ }
208
+
172
209
  .sidebar-backdrop {
173
210
  position: fixed;
174
211
  inset: 0;
@@ -1,23 +1,47 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
+ import X from '@lucide/svelte/icons/x';
5
+ import Button from '../button/Button.svelte';
6
+ import { getSidebarContext } from './context.js';
4
7
 
5
8
  type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
9
  children: Snippet;
7
10
  };
8
11
 
9
12
  let { class: className, children, ...rest }: Props = $props();
13
+
14
+ const sidebar = getSidebarContext();
10
15
  </script>
11
16
 
12
17
  <div {...rest} class={['sidebar-header', className]}>
13
- {@render children()}
18
+ <div class="sidebar-header-content">
19
+ {@render children()}
20
+ </div>
21
+ {#if sidebar.mobile}
22
+ <Button
23
+ variant="ghost"
24
+ size="sm"
25
+ icon={X}
26
+ label="Close sidebar"
27
+ onclick={sidebar.closeMobile}
28
+ />
29
+ {/if}
14
30
  </div>
15
31
 
16
32
  <style>
17
33
  .sidebar-header {
18
34
  display: flex;
19
- flex-direction: column;
35
+ align-items: center;
20
36
  gap: 8px;
21
37
  padding: 8px;
22
38
  }
39
+
40
+ .sidebar-header-content {
41
+ display: flex;
42
+ flex: 1;
43
+ flex-direction: column;
44
+ gap: 8px;
45
+ min-width: 0;
46
+ }
23
47
  </style>