@dorsk/tsumikit 0.1.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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/autoresize.d.ts +11 -0
- package/dist/autoresize.js +24 -0
- package/dist/components/atoms/Badge.svelte +72 -0
- package/dist/components/atoms/Badge.svelte.d.ts +12 -0
- package/dist/components/atoms/Button.svelte +156 -0
- package/dist/components/atoms/Button.svelte.d.ts +13 -0
- package/dist/components/atoms/Card.svelte +46 -0
- package/dist/components/atoms/Card.svelte.d.ts +11 -0
- package/dist/components/atoms/Checkbox.svelte +99 -0
- package/dist/components/atoms/Checkbox.svelte.d.ts +10 -0
- package/dist/components/atoms/Chip.svelte +53 -0
- package/dist/components/atoms/Chip.svelte.d.ts +11 -0
- package/dist/components/atoms/Heading.svelte +66 -0
- package/dist/components/atoms/Heading.svelte.d.ts +13 -0
- package/dist/components/atoms/Icon.svelte +151 -0
- package/dist/components/atoms/Icon.svelte.d.ts +18 -0
- package/dist/components/atoms/Input.svelte +42 -0
- package/dist/components/atoms/Input.svelte.d.ts +10 -0
- package/dist/components/atoms/Link.svelte +31 -0
- package/dist/components/atoms/Link.svelte.d.ts +10 -0
- package/dist/components/atoms/Progress.svelte +59 -0
- package/dist/components/atoms/Progress.svelte.d.ts +9 -0
- package/dist/components/atoms/Select.svelte +95 -0
- package/dist/components/atoms/Select.svelte.d.ts +11 -0
- package/dist/components/atoms/Slider.svelte +136 -0
- package/dist/components/atoms/Slider.svelte.d.ts +14 -0
- package/dist/components/atoms/Switch.svelte +64 -0
- package/dist/components/atoms/Switch.svelte.d.ts +8 -0
- package/dist/components/atoms/Text.svelte +127 -0
- package/dist/components/atoms/Text.svelte.d.ts +16 -0
- package/dist/components/atoms/Textarea.svelte +62 -0
- package/dist/components/atoms/Textarea.svelte.d.ts +11 -0
- package/dist/components/layouts/AppShell.svelte +304 -0
- package/dist/components/layouts/AppShell.svelte.d.ts +21 -0
- package/dist/components/layouts/AutoGrid.svelte +36 -0
- package/dist/components/layouts/AutoGrid.svelte.d.ts +12 -0
- package/dist/components/layouts/Cluster.svelte +45 -0
- package/dist/components/layouts/Cluster.svelte.d.ts +14 -0
- package/dist/components/layouts/Container.svelte +40 -0
- package/dist/components/layouts/Container.svelte.d.ts +13 -0
- package/dist/components/layouts/NavItem.svelte +95 -0
- package/dist/components/layouts/NavItem.svelte.d.ts +14 -0
- package/dist/components/layouts/Stack.svelte +44 -0
- package/dist/components/layouts/Stack.svelte.d.ts +13 -0
- package/dist/components/molecules/Accordion.svelte +94 -0
- package/dist/components/molecules/Accordion.svelte.d.ts +16 -0
- package/dist/components/molecules/CodeBlock.svelte +119 -0
- package/dist/components/molecules/CodeBlock.svelte.d.ts +17 -0
- package/dist/components/molecules/CopyButton.svelte +80 -0
- package/dist/components/molecules/CopyButton.svelte.d.ts +13 -0
- package/dist/components/molecules/Dropzone.svelte +140 -0
- package/dist/components/molecules/Dropzone.svelte.d.ts +13 -0
- package/dist/components/molecules/Field.svelte +57 -0
- package/dist/components/molecules/Field.svelte.d.ts +12 -0
- package/dist/components/molecules/FileButton.svelte +68 -0
- package/dist/components/molecules/FileButton.svelte.d.ts +14 -0
- package/dist/components/molecules/FontScalePicker.svelte +21 -0
- package/dist/components/molecules/FontScalePicker.svelte.d.ts +6 -0
- package/dist/components/molecules/IconButton.svelte +36 -0
- package/dist/components/molecules/IconButton.svelte.d.ts +13 -0
- package/dist/components/molecules/Menu.svelte +120 -0
- package/dist/components/molecules/Menu.svelte.d.ts +17 -0
- package/dist/components/molecules/Modal.svelte +263 -0
- package/dist/components/molecules/Modal.svelte.d.ts +13 -0
- package/dist/components/molecules/OptionButton.svelte +76 -0
- package/dist/components/molecules/OptionButton.svelte.d.ts +10 -0
- package/dist/components/molecules/Popover.svelte +125 -0
- package/dist/components/molecules/Popover.svelte.d.ts +18 -0
- package/dist/components/molecules/RadioGroup.svelte +110 -0
- package/dist/components/molecules/RadioGroup.svelte.d.ts +16 -0
- package/dist/components/molecules/SelectButton.svelte +52 -0
- package/dist/components/molecules/SelectButton.svelte.d.ts +15 -0
- package/dist/components/molecules/Tabs.svelte +119 -0
- package/dist/components/molecules/Tabs.svelte.d.ts +15 -0
- package/dist/components/molecules/ThemePicker.svelte +22 -0
- package/dist/components/molecules/ThemePicker.svelte.d.ts +6 -0
- package/dist/components/molecules/Toaster.svelte +73 -0
- package/dist/components/molecules/Toaster.svelte.d.ts +18 -0
- package/dist/components/molecules/Toggle.svelte +68 -0
- package/dist/components/molecules/Toggle.svelte.d.ts +11 -0
- package/dist/components/molecules/Tooltip.svelte +106 -0
- package/dist/components/molecules/Tooltip.svelte.d.ts +10 -0
- package/dist/components/organisms/DataTable.svelte +145 -0
- package/dist/components/organisms/DataTable.svelte.d.ts +43 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +4 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +56 -0
- package/dist/stores/fontscale.svelte.d.ts +15 -0
- package/dist/stores/fontscale.svelte.js +49 -0
- package/dist/stores/theme.svelte.d.ts +96 -0
- package/dist/stores/theme.svelte.js +71 -0
- package/dist/stores/toast.svelte.d.ts +19 -0
- package/dist/stores/toast.svelte.js +26 -0
- package/dist/styles/app.css +522 -0
- package/dist/styles/variables.css +651 -0
- package/package.json +71 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
export interface AccordionItem {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
/** Panel content for this item. */
|
|
7
|
+
content: Snippet;
|
|
8
|
+
open?: boolean;
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
// Disclosure accordion built on native <details>/<summary> — zero JS for the
|
|
14
|
+
// open/close, full keyboard + a11y for free. When `multiple` is false the
|
|
15
|
+
// items share a `name`, using the platform's exclusive-accordion behavior
|
|
16
|
+
// (only one open at a time). The summary marker is replaced with a rotating
|
|
17
|
+
// chevron.
|
|
18
|
+
import Icon from '../atoms/Icon.svelte';
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
items,
|
|
22
|
+
multiple = true,
|
|
23
|
+
class: klass = ''
|
|
24
|
+
}: {
|
|
25
|
+
items: AccordionItem[];
|
|
26
|
+
multiple?: boolean;
|
|
27
|
+
class?: string;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
// One shared name → native single-open accordion (stable id, derived toggle).
|
|
31
|
+
const gid = `acc-${Math.random().toString(36).slice(2, 8)}`;
|
|
32
|
+
const groupName = $derived(multiple ? undefined : gid);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="accordion {klass}">
|
|
36
|
+
{#each items as item (item.id)}
|
|
37
|
+
<details name={groupName} open={item.open}>
|
|
38
|
+
<summary>
|
|
39
|
+
<span class="acc-title">{item.title}</span>
|
|
40
|
+
<Icon name="chevron-down" />
|
|
41
|
+
</summary>
|
|
42
|
+
<div class="acc-panel">{@render item.content()}</div>
|
|
43
|
+
</details>
|
|
44
|
+
{/each}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.accordion {
|
|
49
|
+
border: 1px solid var(--border);
|
|
50
|
+
border-radius: var(--r-lg);
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
}
|
|
53
|
+
details + details {
|
|
54
|
+
border-top: 1px solid var(--border);
|
|
55
|
+
}
|
|
56
|
+
summary {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
justify-content: space-between;
|
|
60
|
+
gap: var(--sp-2);
|
|
61
|
+
padding: var(--sp-3) var(--sp-4);
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-weight: var(--fw-medium);
|
|
64
|
+
font-size: var(--fs-sm);
|
|
65
|
+
list-style: none;
|
|
66
|
+
user-select: none;
|
|
67
|
+
}
|
|
68
|
+
/* Hide the default disclosure triangle across engines. */
|
|
69
|
+
summary::-webkit-details-marker {
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
summary::marker {
|
|
73
|
+
content: '';
|
|
74
|
+
}
|
|
75
|
+
summary:hover {
|
|
76
|
+
background: var(--bg-elevated-2);
|
|
77
|
+
}
|
|
78
|
+
summary:focus-visible {
|
|
79
|
+
outline: 2px solid var(--accent);
|
|
80
|
+
outline-offset: -2px;
|
|
81
|
+
}
|
|
82
|
+
summary :global(.icon) {
|
|
83
|
+
color: var(--text-muted);
|
|
84
|
+
transition: transform 0.15s var(--ease);
|
|
85
|
+
}
|
|
86
|
+
details[open] summary :global(.icon) {
|
|
87
|
+
transform: rotate(180deg);
|
|
88
|
+
}
|
|
89
|
+
.acc-panel {
|
|
90
|
+
padding: 0 var(--sp-4) var(--sp-4);
|
|
91
|
+
font-size: var(--fs-sm);
|
|
92
|
+
color: var(--text-muted);
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export interface AccordionItem {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
/** Panel content for this item. */
|
|
6
|
+
content: Snippet;
|
|
7
|
+
open?: boolean;
|
|
8
|
+
}
|
|
9
|
+
type $$ComponentProps = {
|
|
10
|
+
items: AccordionItem[];
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const Accordion: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
|
+
type Accordion = ReturnType<typeof Accordion>;
|
|
16
|
+
export default Accordion;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Code block — dependency-free chrome around your code. It does NOT bundle a
|
|
3
|
+
// highlighter (that would break the zero-dep promise and bloat the bundle).
|
|
4
|
+
// Three ways to feed it, cheapest first:
|
|
5
|
+
// • plain `code` (no highlighting, fully escaped);
|
|
6
|
+
// • a `highlight` callback (code, lang) => HTML string you provide;
|
|
7
|
+
// • pre-rendered `html` (e.g. from a server/build step).
|
|
8
|
+
// Class-based highlighters (highlight.js, Prism) are auto-themed: their token
|
|
9
|
+
// classes are mapped to the --syn-* theme tokens globally (see app.css), so
|
|
10
|
+
// the colors track every theme. Provides a language label, copy button,
|
|
11
|
+
// optional line numbers and soft-wrap toggle.
|
|
12
|
+
import CopyButton from './CopyButton.svelte';
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
code,
|
|
16
|
+
lang,
|
|
17
|
+
html,
|
|
18
|
+
highlight,
|
|
19
|
+
filename,
|
|
20
|
+
showLineNumbers = false,
|
|
21
|
+
wrap = false,
|
|
22
|
+
copy = true,
|
|
23
|
+
class: klass = ''
|
|
24
|
+
}: {
|
|
25
|
+
code: string;
|
|
26
|
+
/** Language label (and hint passed to `highlight`). */
|
|
27
|
+
lang?: string;
|
|
28
|
+
/** Pre-highlighted HTML (overrides `highlight`). */
|
|
29
|
+
html?: string;
|
|
30
|
+
/** Highlighter callback returning HTML for `code`. */
|
|
31
|
+
highlight?: (code: string, lang?: string) => string;
|
|
32
|
+
filename?: string;
|
|
33
|
+
showLineNumbers?: boolean;
|
|
34
|
+
wrap?: boolean;
|
|
35
|
+
copy?: boolean;
|
|
36
|
+
class?: string;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
const rendered = $derived(html ?? (highlight ? highlight(code, lang) : null));
|
|
40
|
+
const lines = $derived(showLineNumbers ? code.replace(/\n$/, '').split('\n').length : 0);
|
|
41
|
+
const hasHeader = $derived(!!(filename || lang || copy));
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<figure class="codeblock {klass}">
|
|
45
|
+
{#if hasHeader}
|
|
46
|
+
<figcaption class="cb-head">
|
|
47
|
+
<span class="cb-name">{filename ?? lang ?? ''}</span>
|
|
48
|
+
<div class="spacer"></div>
|
|
49
|
+
{#if copy}<CopyButton text={code} showLabel={false} />{/if}
|
|
50
|
+
</figcaption>
|
|
51
|
+
{/if}
|
|
52
|
+
<div class="cb-body" class:wrap class:numbered={showLineNumbers}>
|
|
53
|
+
{#if showLineNumbers}
|
|
54
|
+
<span class="cb-gutter" aria-hidden="true">
|
|
55
|
+
{#each { length: lines } as _, i (i)}<span>{i + 1}</span>{/each}
|
|
56
|
+
</span>
|
|
57
|
+
{/if}
|
|
58
|
+
<pre class="cb-pre"><code class="cb-code" class:hljs={!!rendered}
|
|
59
|
+
>{#if rendered}{@html rendered}{:else}{code}{/if}</code
|
|
60
|
+
></pre>
|
|
61
|
+
</div>
|
|
62
|
+
</figure>
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.codeblock {
|
|
66
|
+
margin: 0;
|
|
67
|
+
border: 1px solid var(--border);
|
|
68
|
+
border-radius: var(--r-lg);
|
|
69
|
+
background: var(--bg);
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
}
|
|
72
|
+
.cb-head {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: var(--sp-2);
|
|
76
|
+
padding: var(--sp-1) var(--sp-1) var(--sp-1) var(--sp-3);
|
|
77
|
+
background: var(--bg-elevated-2);
|
|
78
|
+
border-bottom: 1px solid var(--border);
|
|
79
|
+
font-size: var(--fs-xs);
|
|
80
|
+
font-family: var(--font-mono);
|
|
81
|
+
color: var(--text-muted);
|
|
82
|
+
}
|
|
83
|
+
.cb-body {
|
|
84
|
+
display: flex;
|
|
85
|
+
overflow-x: auto;
|
|
86
|
+
-webkit-overflow-scrolling: touch;
|
|
87
|
+
}
|
|
88
|
+
.cb-gutter {
|
|
89
|
+
flex: none;
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
padding: var(--sp-3) var(--sp-2);
|
|
93
|
+
text-align: right;
|
|
94
|
+
color: var(--text-faint);
|
|
95
|
+
background: var(--bg-elevated);
|
|
96
|
+
border-right: 1px solid var(--border);
|
|
97
|
+
user-select: none;
|
|
98
|
+
font-family: var(--font-mono);
|
|
99
|
+
font-size: var(--fs-sm);
|
|
100
|
+
line-height: 1.5;
|
|
101
|
+
}
|
|
102
|
+
.cb-pre {
|
|
103
|
+
margin: 0;
|
|
104
|
+
padding: var(--sp-3);
|
|
105
|
+
flex: 1;
|
|
106
|
+
font-family: var(--font-mono);
|
|
107
|
+
font-size: var(--fs-sm);
|
|
108
|
+
line-height: 1.5;
|
|
109
|
+
color: var(--md-text, var(--text));
|
|
110
|
+
tab-size: 2;
|
|
111
|
+
}
|
|
112
|
+
.cb-body.wrap .cb-pre {
|
|
113
|
+
white-space: pre-wrap;
|
|
114
|
+
word-break: break-word;
|
|
115
|
+
}
|
|
116
|
+
.cb-body.numbered .cb-pre {
|
|
117
|
+
white-space: pre; /* keep lines aligned with the gutter */
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
code: string;
|
|
3
|
+
/** Language label (and hint passed to `highlight`). */
|
|
4
|
+
lang?: string;
|
|
5
|
+
/** Pre-highlighted HTML (overrides `highlight`). */
|
|
6
|
+
html?: string;
|
|
7
|
+
/** Highlighter callback returning HTML for `code`. */
|
|
8
|
+
highlight?: (code: string, lang?: string) => string;
|
|
9
|
+
filename?: string;
|
|
10
|
+
showLineNumbers?: boolean;
|
|
11
|
+
wrap?: boolean;
|
|
12
|
+
copy?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
declare const CodeBlock: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
|
+
type CodeBlock = ReturnType<typeof CodeBlock>;
|
|
17
|
+
export default CodeBlock;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Copy-to-clipboard button. Composes the Button atom; copies `text` via the
|
|
3
|
+
// async Clipboard API (with a hidden-textarea fallback for non-secure
|
|
4
|
+
// contexts), then flips to a transient "copied" state (check icon + label)
|
|
5
|
+
// that resets after `resetMs`. A visually-hidden aria-live region announces
|
|
6
|
+
// the result for screen readers. Dependency-free.
|
|
7
|
+
import Button from '../atoms/Button.svelte';
|
|
8
|
+
import Icon from '../atoms/Icon.svelte';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
text,
|
|
12
|
+
label = 'Copy',
|
|
13
|
+
copiedLabel = 'Copied',
|
|
14
|
+
variant = 'ghost',
|
|
15
|
+
showLabel = true,
|
|
16
|
+
resetMs = 1500,
|
|
17
|
+
class: klass = ''
|
|
18
|
+
}: {
|
|
19
|
+
/** The string to copy. */
|
|
20
|
+
text: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
copiedLabel?: string;
|
|
23
|
+
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
24
|
+
showLabel?: boolean;
|
|
25
|
+
resetMs?: number;
|
|
26
|
+
class?: string;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
let copied = $state(false);
|
|
30
|
+
let status = $state('');
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
|
|
33
|
+
async function copy() {
|
|
34
|
+
const ok = await writeClipboard(text);
|
|
35
|
+
copied = ok;
|
|
36
|
+
status = ok ? copiedLabel : 'Copy failed';
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
timer = setTimeout(() => {
|
|
39
|
+
copied = false;
|
|
40
|
+
status = '';
|
|
41
|
+
}, resetMs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeClipboard(value: string): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
if (navigator.clipboard?.writeText) {
|
|
47
|
+
await navigator.clipboard.writeText(value);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* fall through to legacy path */
|
|
52
|
+
}
|
|
53
|
+
// Fallback for insecure contexts / older browsers.
|
|
54
|
+
try {
|
|
55
|
+
const ta = document.createElement('textarea');
|
|
56
|
+
ta.value = value;
|
|
57
|
+
ta.style.position = 'fixed';
|
|
58
|
+
ta.style.opacity = '0';
|
|
59
|
+
document.body.appendChild(ta);
|
|
60
|
+
ta.select();
|
|
61
|
+
const ok = document.execCommand('copy');
|
|
62
|
+
document.body.removeChild(ta);
|
|
63
|
+
return ok;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<Button
|
|
71
|
+
{variant}
|
|
72
|
+
class={klass}
|
|
73
|
+
onclick={copy}
|
|
74
|
+
aria-label={copied ? copiedLabel : label}
|
|
75
|
+
title={label}
|
|
76
|
+
>
|
|
77
|
+
<Icon name={copied ? 'check' : 'copy'} />
|
|
78
|
+
{#if showLabel}<span>{copied ? copiedLabel : label}</span>{/if}
|
|
79
|
+
</Button>
|
|
80
|
+
<span class="sr-only" role="status" aria-live="polite">{status}</span>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** The string to copy. */
|
|
3
|
+
text: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
copiedLabel?: string;
|
|
6
|
+
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
7
|
+
showLabel?: boolean;
|
|
8
|
+
resetMs?: number;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const CopyButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type CopyButton = ReturnType<typeof CopyButton>;
|
|
13
|
+
export default CopyButton;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Drag-and-drop file area with a click/keyboard fallback to the file dialog.
|
|
3
|
+
// Emits chosen/dropped files via `onfiles`. Filters by `accept` (extensions
|
|
4
|
+
// or mime patterns). Dependency-free; the whole zone is a labelled button so
|
|
5
|
+
// it's keyboard-operable, and a visually-hidden <input type=file> backs it.
|
|
6
|
+
import type { Snippet } from 'svelte';
|
|
7
|
+
import Icon from '../atoms/Icon.svelte';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
onfiles,
|
|
11
|
+
accept,
|
|
12
|
+
multiple = true,
|
|
13
|
+
disabled = false,
|
|
14
|
+
label = 'Drop files here',
|
|
15
|
+
hint = 'or click to browse',
|
|
16
|
+
children
|
|
17
|
+
}: {
|
|
18
|
+
onfiles: (files: File[]) => void;
|
|
19
|
+
accept?: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
label?: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
children?: Snippet;
|
|
25
|
+
} = $props();
|
|
26
|
+
|
|
27
|
+
let over = $state(false);
|
|
28
|
+
let input = $state<HTMLInputElement | null>(null);
|
|
29
|
+
|
|
30
|
+
function accepted(files: File[]): File[] {
|
|
31
|
+
if (!accept) return files;
|
|
32
|
+
const pats = accept.split(',').map((s) => s.trim().toLowerCase());
|
|
33
|
+
return files.filter((f) =>
|
|
34
|
+
pats.some((p) =>
|
|
35
|
+
p.startsWith('.')
|
|
36
|
+
? f.name.toLowerCase().endsWith(p)
|
|
37
|
+
: p.endsWith('/*')
|
|
38
|
+
? f.type.toLowerCase().startsWith(p.slice(0, -1))
|
|
39
|
+
: f.type.toLowerCase() === p
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
function emit(list: FileList | null | undefined) {
|
|
44
|
+
let files = Array.from(list ?? []);
|
|
45
|
+
files = accepted(files);
|
|
46
|
+
if (!multiple) files = files.slice(0, 1);
|
|
47
|
+
if (files.length) onfiles(files);
|
|
48
|
+
}
|
|
49
|
+
function onDrop(e: DragEvent) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
over = false;
|
|
52
|
+
if (disabled) return;
|
|
53
|
+
emit(e.dataTransfer?.files);
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div
|
|
58
|
+
class="dz"
|
|
59
|
+
class:over
|
|
60
|
+
class:disabled
|
|
61
|
+
role="button"
|
|
62
|
+
tabindex={disabled ? -1 : 0}
|
|
63
|
+
aria-label="{label}. {hint}"
|
|
64
|
+
aria-disabled={disabled || undefined}
|
|
65
|
+
ondragover={(e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
if (!disabled) over = true;
|
|
68
|
+
}}
|
|
69
|
+
ondragleave={() => (over = false)}
|
|
70
|
+
ondrop={onDrop}
|
|
71
|
+
onclick={() => !disabled && input?.click()}
|
|
72
|
+
onkeydown={(e) => {
|
|
73
|
+
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
input?.click();
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<input
|
|
80
|
+
bind:this={input}
|
|
81
|
+
class="sr-only"
|
|
82
|
+
type="file"
|
|
83
|
+
{accept}
|
|
84
|
+
{multiple}
|
|
85
|
+
{disabled}
|
|
86
|
+
tabindex="-1"
|
|
87
|
+
onchange={(e) => emit((e.currentTarget as HTMLInputElement).files)}
|
|
88
|
+
/>
|
|
89
|
+
{#if children}
|
|
90
|
+
{@render children()}
|
|
91
|
+
{:else}
|
|
92
|
+
<Icon name="upload" size={28} />
|
|
93
|
+
<span class="dz-label">{label}</span>
|
|
94
|
+
<span class="dz-hint">{hint}</span>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<style>
|
|
99
|
+
.dz {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
gap: var(--sp-2);
|
|
105
|
+
padding: var(--sp-8) var(--sp-4);
|
|
106
|
+
text-align: center;
|
|
107
|
+
color: var(--text-muted);
|
|
108
|
+
background: var(--bg);
|
|
109
|
+
border: 2px dashed var(--border-strong);
|
|
110
|
+
border-radius: var(--r-lg);
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
transition:
|
|
113
|
+
border-color 0.12s var(--ease),
|
|
114
|
+
background 0.12s var(--ease),
|
|
115
|
+
color 0.12s var(--ease);
|
|
116
|
+
}
|
|
117
|
+
.dz:hover:not(.disabled),
|
|
118
|
+
.dz:focus-visible {
|
|
119
|
+
border-color: var(--accent);
|
|
120
|
+
color: var(--text);
|
|
121
|
+
outline: none;
|
|
122
|
+
}
|
|
123
|
+
.dz.over {
|
|
124
|
+
border-color: var(--accent);
|
|
125
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
|
|
126
|
+
color: var(--text);
|
|
127
|
+
}
|
|
128
|
+
.dz.disabled {
|
|
129
|
+
opacity: 0.45;
|
|
130
|
+
cursor: not-allowed;
|
|
131
|
+
}
|
|
132
|
+
.dz-label {
|
|
133
|
+
font-weight: var(--fw-medium);
|
|
134
|
+
font-size: var(--fs-sm);
|
|
135
|
+
}
|
|
136
|
+
.dz-hint {
|
|
137
|
+
font-size: var(--fs-xs);
|
|
138
|
+
color: var(--text-faint);
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
onfiles: (files: File[]) => void;
|
|
4
|
+
accept?: string;
|
|
5
|
+
multiple?: boolean;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
label?: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
children?: Snippet;
|
|
10
|
+
};
|
|
11
|
+
declare const Dropzone: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type Dropzone = ReturnType<typeof Dropzone>;
|
|
13
|
+
export default Dropzone;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Form field wrapper: a label above a slotted control, with optional hint and
|
|
3
|
+
// error text. Replaces the ad-hoc `<div class="field"><label class="label">`
|
|
4
|
+
// markup. Renders a real <label for> when `for` is given (associates with the
|
|
5
|
+
// control), else a plain <span> for control groups (radio/segment rows).
|
|
6
|
+
import type { Snippet } from 'svelte';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
label,
|
|
10
|
+
for: forId,
|
|
11
|
+
hint,
|
|
12
|
+
error,
|
|
13
|
+
class: klass = '',
|
|
14
|
+
children
|
|
15
|
+
}: {
|
|
16
|
+
label?: string;
|
|
17
|
+
for?: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
class?: string;
|
|
21
|
+
children?: Snippet;
|
|
22
|
+
} = $props();
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="field {klass}">
|
|
26
|
+
{#if label}
|
|
27
|
+
{#if forId}
|
|
28
|
+
<label class="label" for={forId}>{label}</label>
|
|
29
|
+
{:else}
|
|
30
|
+
<span class="label">{label}</span>
|
|
31
|
+
{/if}
|
|
32
|
+
{/if}
|
|
33
|
+
{@render children?.()}
|
|
34
|
+
{#if hint}<span class="hint">{hint}</span>{/if}
|
|
35
|
+
{#if error}<span class="error">{error}</span>{/if}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.field {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: var(--sp-1);
|
|
43
|
+
}
|
|
44
|
+
.label {
|
|
45
|
+
font-size: var(--fs-sm);
|
|
46
|
+
font-weight: var(--fw-medium);
|
|
47
|
+
color: var(--text-muted);
|
|
48
|
+
}
|
|
49
|
+
.hint {
|
|
50
|
+
font-size: var(--fs-xs);
|
|
51
|
+
color: var(--text-faint);
|
|
52
|
+
}
|
|
53
|
+
.error {
|
|
54
|
+
font-size: var(--fs-xs);
|
|
55
|
+
color: var(--danger);
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
label?: string;
|
|
4
|
+
for?: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
children?: Snippet;
|
|
9
|
+
};
|
|
10
|
+
declare const Field: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type Field = ReturnType<typeof Field>;
|
|
12
|
+
export default Field;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// File-picker button. A real <input type="file"> visually hidden inside a
|
|
3
|
+
// <label> styled as a button — so it's keyboard-focusable and works with zero
|
|
4
|
+
// JS to open the dialog. Emits the chosen files via `onfiles`. Dependency-free.
|
|
5
|
+
import Icon from '../atoms/Icon.svelte';
|
|
6
|
+
import type { IconName } from '../atoms/Icon.svelte';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
onfiles,
|
|
10
|
+
label = 'Choose file',
|
|
11
|
+
icon = 'upload',
|
|
12
|
+
accept,
|
|
13
|
+
multiple = false,
|
|
14
|
+
disabled = false,
|
|
15
|
+
variant = 'default',
|
|
16
|
+
class: klass = ''
|
|
17
|
+
}: {
|
|
18
|
+
onfiles: (files: File[]) => void;
|
|
19
|
+
label?: string;
|
|
20
|
+
icon?: IconName;
|
|
21
|
+
accept?: string;
|
|
22
|
+
multiple?: boolean;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
variant?: 'default' | 'primary' | 'ghost';
|
|
25
|
+
class?: string;
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
function onchange(e: Event) {
|
|
29
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
30
|
+
const files = Array.from(input.files ?? []);
|
|
31
|
+
if (files.length) onfiles(files);
|
|
32
|
+
input.value = ''; // allow re-picking the same file
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<label
|
|
37
|
+
class="btn file-btn {klass}"
|
|
38
|
+
class:btn-primary={variant === 'primary'}
|
|
39
|
+
class:btn-ghost={variant === 'ghost'}
|
|
40
|
+
class:disabled
|
|
41
|
+
>
|
|
42
|
+
<input
|
|
43
|
+
class="sr-only"
|
|
44
|
+
type="file"
|
|
45
|
+
{accept}
|
|
46
|
+
{multiple}
|
|
47
|
+
{disabled}
|
|
48
|
+
onchange={onchange}
|
|
49
|
+
/>
|
|
50
|
+
<Icon name={icon} />
|
|
51
|
+
<span>{label}</span>
|
|
52
|
+
</label>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.file-btn {
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
/* The hidden input keeps focusability (sr-only, not display:none), so mirror
|
|
59
|
+
its focus onto the label for a visible ring. */
|
|
60
|
+
.file-btn:focus-within {
|
|
61
|
+
outline: 2px solid var(--accent);
|
|
62
|
+
outline-offset: 2px;
|
|
63
|
+
}
|
|
64
|
+
.file-btn.disabled {
|
|
65
|
+
opacity: 0.45;
|
|
66
|
+
cursor: not-allowed;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IconName } from '../atoms/Icon.svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
onfiles: (files: File[]) => void;
|
|
4
|
+
label?: string;
|
|
5
|
+
icon?: IconName;
|
|
6
|
+
accept?: string;
|
|
7
|
+
multiple?: boolean;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
variant?: 'default' | 'primary' | 'ghost';
|
|
10
|
+
class?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const FileButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type FileButton = ReturnType<typeof FileButton>;
|
|
14
|
+
export default FileButton;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// UI text-size switcher. Same compact native-select-over-button pattern as
|
|
3
|
+
// ThemePicker, wired to the font-scale store. Drives --fs-scale (text only) —
|
|
4
|
+
// chrome dimensions stay fixed. Glyph is the conventional "A".
|
|
5
|
+
import SelectButton from './SelectButton.svelte';
|
|
6
|
+
import { fontScale, SCALE_LEVELS } from '../../stores/fontscale.svelte';
|
|
7
|
+
|
|
8
|
+
let { class: klass = '' }: { class?: string } = $props();
|
|
9
|
+
|
|
10
|
+
const options = SCALE_LEVELS.map((l) => ({ value: l.id, label: l.label }));
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<SelectButton
|
|
14
|
+
class={klass}
|
|
15
|
+
glyph="A"
|
|
16
|
+
label="Text size"
|
|
17
|
+
title="Text size"
|
|
18
|
+
value={fontScale.levelId}
|
|
19
|
+
{options}
|
|
20
|
+
onchange={(v) => fontScale.set(v)}
|
|
21
|
+
/>
|