sh3-core 0.11.8 → 0.13.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/dist/__test__/reset.js +2 -0
- package/dist/actions/MenuButton.svelte +2 -1
- package/dist/actions/contextMenuModel.js +8 -0
- package/dist/actions/contextMenuModel.test.js +22 -2
- package/dist/actions/listeners.js +28 -2
- package/dist/actions/listeners.test.js +87 -1
- package/dist/actions/scope-helpers.d.ts +17 -0
- package/dist/actions/scope-helpers.js +37 -0
- package/dist/actions/scope-helpers.test.js +33 -1
- package/dist/api.d.ts +18 -1
- package/dist/api.js +15 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +25 -5
- package/dist/apps/lifecycle.test.js +95 -0
- package/dist/host.js +30 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/navigation/back-stack.d.ts +29 -0
- package/dist/navigation/back-stack.js +87 -0
- package/dist/navigation/back-stack.test.d.ts +1 -0
- package/dist/navigation/back-stack.test.js +145 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +6 -0
- package/dist/navigation/platform-web.d.ts +3 -0
- package/dist/navigation/platform-web.js +54 -0
- package/dist/navigation/platform-web.test.d.ts +1 -0
- package/dist/navigation/platform-web.test.js +96 -0
- package/dist/overlays/modal.js +7 -0
- package/dist/overlays/modal.test.js +35 -0
- package/dist/overlays/popup.js +7 -0
- package/dist/overlays/popup.test.js +33 -0
- package/dist/platform/index.d.ts +15 -0
- package/dist/platform/index.js +47 -0
- package/dist/primitives/base.css +17 -6
- package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
- package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
- package/dist/primitives/widgets/Field.svelte +124 -0
- package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
- package/dist/primitives/widgets/FilePicker.d.ts +3 -0
- package/dist/primitives/widgets/FilePicker.js +19 -0
- package/dist/primitives/widgets/FilePicker.svelte +79 -0
- package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
- package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/FilePicker.test.js +44 -0
- package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
- package/dist/primitives/widgets/IconToggleGroup.js +8 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
- package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
- package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
- package/dist/primitives/widgets/NumberInput.d.ts +6 -0
- package/dist/primitives/widgets/NumberInput.js +19 -0
- package/dist/primitives/widgets/NumberInput.svelte +167 -0
- package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
- package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
- package/dist/primitives/widgets/NumberInput.test.js +28 -0
- package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
- package/dist/primitives/widgets/RangeSlider.js +7 -0
- package/dist/primitives/widgets/RangeSlider.svelte +124 -0
- package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
- package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
- package/dist/primitives/widgets/RangeSlider.test.js +14 -0
- package/dist/primitives/widgets/Segmented.d.ts +9 -0
- package/dist/primitives/widgets/Segmented.js +28 -0
- package/dist/primitives/widgets/Segmented.svelte +82 -0
- package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
- package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
- package/dist/primitives/widgets/Segmented.test.js +24 -0
- package/dist/primitives/widgets/Select.d.ts +11 -0
- package/dist/primitives/widgets/Select.js +42 -0
- package/dist/primitives/widgets/Select.svelte +163 -0
- package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
- package/dist/primitives/widgets/Select.test.d.ts +1 -0
- package/dist/primitives/widgets/Select.test.js +68 -0
- package/dist/primitives/widgets/Slider.d.ts +6 -0
- package/dist/primitives/widgets/Slider.js +19 -0
- package/dist/primitives/widgets/Slider.svelte +205 -0
- package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
- package/dist/primitives/widgets/Slider.test.d.ts +1 -0
- package/dist/primitives/widgets/Slider.test.js +31 -0
- package/dist/primitives/widgets/SliderGroup.svelte +58 -0
- package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
- package/dist/primitives/widgets/Textarea.svelte +81 -0
- package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
- package/dist/primitives/widgets/_select-listbox.svelte +228 -0
- package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/shell-shard/Terminal.svelte +1 -4
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/reset.d.ts +2 -0
- package/dist/shell-shard/verbs/reset.js +26 -0
- package/dist/tokens.css +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/overlays/modal.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { mount, unmount } from 'svelte';
|
|
40
40
|
import ModalFrame from './ModalFrame.svelte';
|
|
41
41
|
import { getLayerRoot } from './roots';
|
|
42
|
+
import { registerDismissable } from '../navigation/back-stack';
|
|
42
43
|
const stack = [];
|
|
43
44
|
let escapeInstalled = false;
|
|
44
45
|
let backdrop = null;
|
|
@@ -116,6 +117,12 @@ function openModal(Content, props, options) {
|
|
|
116
117
|
const handle = {
|
|
117
118
|
close: () => removeEntry(entry),
|
|
118
119
|
};
|
|
120
|
+
const dismissReg = registerDismissable(() => handle.close());
|
|
121
|
+
const originalClose = handle.close;
|
|
122
|
+
handle.close = () => {
|
|
123
|
+
dismissReg.unregister();
|
|
124
|
+
originalClose();
|
|
125
|
+
};
|
|
119
126
|
const frame = mount(ModalFrame, {
|
|
120
127
|
target: host,
|
|
121
128
|
props: {
|
|
@@ -53,3 +53,38 @@ describe('modal — backdrop dismiss policy', () => {
|
|
|
53
53
|
expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
|
+
describe('modal — back-cascade integration', () => {
|
|
57
|
+
let layerRoot;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
layerRoot = makeLayerRoot();
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
modalManager.closeAll();
|
|
63
|
+
teardownLayerRoot(layerRoot);
|
|
64
|
+
});
|
|
65
|
+
it('opens register a dismissable; back closes the topmost modal', async () => {
|
|
66
|
+
const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
|
|
67
|
+
__resetBackStackForTest();
|
|
68
|
+
modalManager.open(DummyFrame, {});
|
|
69
|
+
modalManager.open(DummyFrame, {});
|
|
70
|
+
await tick();
|
|
71
|
+
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(2);
|
|
72
|
+
dispatchBack();
|
|
73
|
+
await tick();
|
|
74
|
+
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(1);
|
|
75
|
+
dispatchBack();
|
|
76
|
+
await tick();
|
|
77
|
+
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
it('programmatic close unregisters the dismissable (no double-close)', async () => {
|
|
80
|
+
const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
|
|
81
|
+
__resetBackStackForTest();
|
|
82
|
+
const handle = modalManager.open(DummyFrame, {});
|
|
83
|
+
await tick();
|
|
84
|
+
handle.close();
|
|
85
|
+
handle.close();
|
|
86
|
+
await tick();
|
|
87
|
+
expect(() => dispatchBack()).not.toThrow();
|
|
88
|
+
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/dist/overlays/popup.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { mount, unmount } from 'svelte';
|
|
29
29
|
import PopupFrame from './PopupFrame.svelte';
|
|
30
30
|
import { getLayerRoot } from './roots';
|
|
31
|
+
import { registerDismissable } from '../navigation/back-stack';
|
|
31
32
|
/**
|
|
32
33
|
* Convert a PopupAnchor to a DOMRect.
|
|
33
34
|
* - HTMLElement: uses its live bounding rect.
|
|
@@ -91,6 +92,12 @@ function showPopup(Content, options, props) {
|
|
|
91
92
|
const handle = {
|
|
92
93
|
close: () => removeEntry(entry),
|
|
93
94
|
};
|
|
95
|
+
const dismissReg = registerDismissable(() => handle.close());
|
|
96
|
+
const originalClose = handle.close;
|
|
97
|
+
handle.close = () => {
|
|
98
|
+
dismissReg.unregister();
|
|
99
|
+
originalClose();
|
|
100
|
+
};
|
|
94
101
|
const frame = mount(PopupFrame, {
|
|
95
102
|
target: host,
|
|
96
103
|
props: {
|
|
@@ -93,3 +93,36 @@ describe('popup — P.2 HTMLElement anchor regression', () => {
|
|
|
93
93
|
anchor.remove();
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
describe('popup — back-cascade integration', () => {
|
|
97
|
+
let layerRoot;
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.stubGlobal('innerWidth', 2000);
|
|
100
|
+
vi.stubGlobal('innerHeight', 2000);
|
|
101
|
+
layerRoot = makeLayerRoot();
|
|
102
|
+
});
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
__resetPopupManagerForTest();
|
|
105
|
+
teardownLayerRoot(layerRoot);
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
it('opening a popup registers a dismissable; back closes it', async () => {
|
|
109
|
+
const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
|
|
110
|
+
__resetBackStackForTest();
|
|
111
|
+
popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
|
|
112
|
+
await tick();
|
|
113
|
+
expect(layerRoot.querySelector('.sh3-popup-host')).not.toBeNull();
|
|
114
|
+
dispatchBack();
|
|
115
|
+
await tick();
|
|
116
|
+
expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
it('programmatic close does not leave a stale dismissable', async () => {
|
|
119
|
+
const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
|
|
120
|
+
__resetBackStackForTest();
|
|
121
|
+
const handle = popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
|
|
122
|
+
await tick();
|
|
123
|
+
handle.close();
|
|
124
|
+
await tick();
|
|
125
|
+
expect(() => dispatchBack()).not.toThrow();
|
|
126
|
+
expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/dist/platform/index.d.ts
CHANGED
|
@@ -8,3 +8,18 @@ export interface PlatformResult {
|
|
|
8
8
|
localOwner: boolean;
|
|
9
9
|
}
|
|
10
10
|
export declare function resolvePlatform(): Promise<PlatformResult>;
|
|
11
|
+
/**
|
|
12
|
+
* Erase all persisted SH3 state for this user/device.
|
|
13
|
+
*
|
|
14
|
+
* Wipes:
|
|
15
|
+
* - Tauri plugin-store files (`workspace.json`, `user.json`) when running
|
|
16
|
+
* inside a Tauri webview
|
|
17
|
+
* - localStorage keys with the `sh3:` prefix (web zones + theme overrides;
|
|
18
|
+
* also active inside the Tauri webview)
|
|
19
|
+
* - the `sh3-packages` IndexedDB database (installed bundle registry)
|
|
20
|
+
*
|
|
21
|
+
* Intentionally preserves the `sh3-documents` IndexedDB so user-authored
|
|
22
|
+
* content survives a reset. Callers are expected to follow this with a
|
|
23
|
+
* `location.reload()` so a clean boot picks up the cleared state.
|
|
24
|
+
*/
|
|
25
|
+
export declare function wipeUserData(): Promise<void>;
|
package/dist/platform/index.js
CHANGED
|
@@ -31,3 +31,50 @@ export async function resolvePlatform() {
|
|
|
31
31
|
return { backends: null, localOwner: DEV };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Erase all persisted SH3 state for this user/device.
|
|
36
|
+
*
|
|
37
|
+
* Wipes:
|
|
38
|
+
* - Tauri plugin-store files (`workspace.json`, `user.json`) when running
|
|
39
|
+
* inside a Tauri webview
|
|
40
|
+
* - localStorage keys with the `sh3:` prefix (web zones + theme overrides;
|
|
41
|
+
* also active inside the Tauri webview)
|
|
42
|
+
* - the `sh3-packages` IndexedDB database (installed bundle registry)
|
|
43
|
+
*
|
|
44
|
+
* Intentionally preserves the `sh3-documents` IndexedDB so user-authored
|
|
45
|
+
* content survives a reset. Callers are expected to follow this with a
|
|
46
|
+
* `location.reload()` so a clean boot picks up the cleared state.
|
|
47
|
+
*/
|
|
48
|
+
export async function wipeUserData() {
|
|
49
|
+
// Tauri stores live on disk via plugin-store; localStorage/IDB don't reach them.
|
|
50
|
+
try {
|
|
51
|
+
const { LazyStore } = await import('@tauri-apps/plugin-store');
|
|
52
|
+
const workspace = new LazyStore('workspace.json', { defaults: {}, autoSave: true });
|
|
53
|
+
const user = new LazyStore('user.json', { defaults: {}, autoSave: true });
|
|
54
|
+
await workspace.clear();
|
|
55
|
+
await user.clear();
|
|
56
|
+
}
|
|
57
|
+
catch (_a) {
|
|
58
|
+
// Not in Tauri — nothing to clear on disk.
|
|
59
|
+
}
|
|
60
|
+
if (typeof localStorage !== 'undefined') {
|
|
61
|
+
const keys = [];
|
|
62
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
63
|
+
const k = localStorage.key(i);
|
|
64
|
+
if (k && k.startsWith('sh3:'))
|
|
65
|
+
keys.push(k);
|
|
66
|
+
}
|
|
67
|
+
for (const k of keys)
|
|
68
|
+
localStorage.removeItem(k);
|
|
69
|
+
}
|
|
70
|
+
if (typeof indexedDB !== 'undefined') {
|
|
71
|
+
await new Promise((resolve) => {
|
|
72
|
+
const req = indexedDB.deleteDatabase('sh3-packages');
|
|
73
|
+
req.onsuccess = () => resolve();
|
|
74
|
+
// Swallow errors/blocked: a stale lock shouldn't abort the wipe;
|
|
75
|
+
// the next boot will re-create the DB empty either way.
|
|
76
|
+
req.onerror = () => resolve();
|
|
77
|
+
req.onblocked = () => resolve();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/primitives/base.css
CHANGED
|
@@ -52,7 +52,8 @@ input[type="tel"],
|
|
|
52
52
|
input[type="number"],
|
|
53
53
|
textarea,
|
|
54
54
|
.shell-base-input {
|
|
55
|
-
padding: var(--shell-
|
|
55
|
+
padding: 0 var(--shell-field-pad-x);
|
|
56
|
+
height: var(--shell-field-height-md);
|
|
56
57
|
background: var(--shell-input-bg);
|
|
57
58
|
color: var(--shell-fg);
|
|
58
59
|
border: 1px solid var(--shell-border);
|
|
@@ -82,6 +83,8 @@ textarea:disabled,
|
|
|
82
83
|
textarea {
|
|
83
84
|
resize: vertical;
|
|
84
85
|
min-height: calc(var(--shell-line) * 3em);
|
|
86
|
+
height: auto;
|
|
87
|
+
padding: var(--shell-pad-sm) var(--shell-field-pad-x);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
/* ── Checkbox & radio ────────────────────────────────────────────────── */
|
|
@@ -103,8 +106,10 @@ input[type="radio"].shell-base-radio {
|
|
|
103
106
|
.shell-base-check { border-radius: var(--shell-radius-sm); }
|
|
104
107
|
.shell-base-radio { border-radius: 50%; }
|
|
105
108
|
|
|
106
|
-
|
|
107
|
-
.shell-base-
|
|
109
|
+
/* Need the input[type] prefix to outweigh the base
|
|
110
|
+
`input[type="checkbox"].shell-base-check` rule's specificity. */
|
|
111
|
+
input[type="checkbox"].shell-base-check:checked,
|
|
112
|
+
input[type="radio"].shell-base-radio:checked {
|
|
108
113
|
background: var(--shell-accent);
|
|
109
114
|
border-color: var(--shell-accent);
|
|
110
115
|
}
|
|
@@ -145,11 +150,13 @@ input[type="checkbox"].shell-base-switch {
|
|
|
145
150
|
width: 28px;
|
|
146
151
|
height: 16px;
|
|
147
152
|
margin: 0;
|
|
148
|
-
background: var(--shell-
|
|
153
|
+
background: var(--shell-bg-sunken);
|
|
154
|
+
border: 1px solid var(--shell-border-strong);
|
|
149
155
|
border-radius: 999px;
|
|
150
156
|
cursor: pointer;
|
|
151
|
-
transition: background 120ms ease;
|
|
157
|
+
transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
|
152
158
|
flex-shrink: 0;
|
|
159
|
+
box-sizing: border-box;
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
.shell-base-switch::before {
|
|
@@ -164,7 +171,11 @@ input[type="checkbox"].shell-base-switch {
|
|
|
164
171
|
transition: transform 120ms ease;
|
|
165
172
|
}
|
|
166
173
|
|
|
167
|
-
.shell-base-switch:checked {
|
|
174
|
+
input[type="checkbox"].shell-base-switch:checked {
|
|
175
|
+
background: var(--shell-accent);
|
|
176
|
+
border-color: var(--shell-accent);
|
|
177
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 25%, transparent);
|
|
178
|
+
}
|
|
168
179
|
.shell-base-switch:checked::before {
|
|
169
180
|
transform: translateX(12px);
|
|
170
181
|
background: var(--shell-fg-on-accent);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { shell } from '../../shellRuntime.svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable('#000000'),
|
|
6
|
+
label,
|
|
7
|
+
disabled = false,
|
|
8
|
+
size = 'md',
|
|
9
|
+
}: {
|
|
10
|
+
value?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
size?: 'sm' | 'md';
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
let trigger: HTMLButtonElement | undefined;
|
|
17
|
+
|
|
18
|
+
async function open() {
|
|
19
|
+
if (disabled) return;
|
|
20
|
+
const result = await shell.color.pick({ initial: value, anchor: trigger });
|
|
21
|
+
if (result !== null && result !== undefined) value = result;
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<label class="sh3-swatch" class:sh3-swatch--sm={size === 'sm'}>
|
|
26
|
+
{#if label}<span class="sh3-swatch__label">{label}</span>{/if}
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="sh3-swatch__btn"
|
|
30
|
+
bind:this={trigger}
|
|
31
|
+
{disabled}
|
|
32
|
+
onclick={open}
|
|
33
|
+
aria-label="{label ?? 'Color'} (current {value})"
|
|
34
|
+
style:--swatch-color={value}
|
|
35
|
+
><span class="sh3-swatch__dot"></span><span class="sh3-swatch__hex">{value}</span></button>
|
|
36
|
+
</label>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.sh3-swatch { display: inline-flex; flex-direction: column; gap: 4px; }
|
|
40
|
+
.sh3-swatch__label { font-size: 0.75rem; color: var(--shell-fg-muted); }
|
|
41
|
+
.sh3-swatch__btn {
|
|
42
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
43
|
+
height: var(--shell-field-height-md);
|
|
44
|
+
padding: 0 var(--shell-field-pad-x);
|
|
45
|
+
background: var(--shell-input-bg);
|
|
46
|
+
border: 1px solid var(--shell-border);
|
|
47
|
+
border-radius: var(--shell-widget-radius);
|
|
48
|
+
color: var(--shell-fg);
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
font: inherit;
|
|
51
|
+
}
|
|
52
|
+
.sh3-swatch--sm .sh3-swatch__btn { height: var(--shell-field-height-sm); }
|
|
53
|
+
.sh3-swatch__btn:hover:not(:disabled) {
|
|
54
|
+
background: var(--shell-bg-elevated);
|
|
55
|
+
filter: none;
|
|
56
|
+
}
|
|
57
|
+
.sh3-swatch__btn:focus-visible { outline: none; box-shadow: var(--shell-focus-ring); }
|
|
58
|
+
.sh3-swatch__btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
59
|
+
.sh3-swatch__dot {
|
|
60
|
+
width: 16px; height: 16px;
|
|
61
|
+
border: 1px solid var(--shell-border-strong);
|
|
62
|
+
border-radius: var(--shell-radius-sm);
|
|
63
|
+
background: var(--swatch-color);
|
|
64
|
+
}
|
|
65
|
+
.sh3-swatch__hex { font-family: var(--shell-font-mono); font-size: 0.75rem; }
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
value?: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
size?: 'sm' | 'md';
|
|
6
|
+
};
|
|
7
|
+
declare const ColorSwatch: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
8
|
+
type ColorSwatch = ReturnType<typeof ColorSwatch>;
|
|
9
|
+
export default ColorSwatch;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable(''),
|
|
6
|
+
type = 'text',
|
|
7
|
+
label,
|
|
8
|
+
placeholder,
|
|
9
|
+
prefix,
|
|
10
|
+
suffix,
|
|
11
|
+
helper,
|
|
12
|
+
error,
|
|
13
|
+
disabled = false,
|
|
14
|
+
invalid = false,
|
|
15
|
+
size = 'md',
|
|
16
|
+
required = false,
|
|
17
|
+
autocomplete,
|
|
18
|
+
}: {
|
|
19
|
+
value?: string;
|
|
20
|
+
type?: 'text' | 'email' | 'password' | 'search' | 'url' | 'tel';
|
|
21
|
+
label?: string;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
prefix?: Snippet;
|
|
24
|
+
suffix?: Snippet;
|
|
25
|
+
helper?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
invalid?: boolean;
|
|
29
|
+
size?: 'sm' | 'md';
|
|
30
|
+
required?: boolean;
|
|
31
|
+
autocomplete?: AutoFill;
|
|
32
|
+
} = $props();
|
|
33
|
+
|
|
34
|
+
const showError = $derived(invalid && !!error);
|
|
35
|
+
const helperText = $derived(showError ? error : helper);
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<label class="sh3-field" class:sh3-field--invalid={invalid} class:sh3-field--sm={size === 'sm'}>
|
|
39
|
+
{#if label}<span class="sh3-field__label">{label}{#if required}<span aria-hidden="true"> *</span>{/if}</span>{/if}
|
|
40
|
+
<span class="sh3-field__row">
|
|
41
|
+
{#if prefix}<span class="sh3-field__affix">{@render prefix()}</span>{/if}
|
|
42
|
+
<input
|
|
43
|
+
class="sh3-field__input"
|
|
44
|
+
{type}
|
|
45
|
+
{placeholder}
|
|
46
|
+
{disabled}
|
|
47
|
+
{required}
|
|
48
|
+
{autocomplete}
|
|
49
|
+
aria-invalid={invalid || undefined}
|
|
50
|
+
bind:value
|
|
51
|
+
/>
|
|
52
|
+
{#if suffix}<span class="sh3-field__affix">{@render suffix()}</span>{/if}
|
|
53
|
+
</span>
|
|
54
|
+
{#if helperText}<span class="sh3-field__helper" class:sh3-field__helper--error={showError}>{helperText}</span>{/if}
|
|
55
|
+
</label>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.sh3-field {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 4px;
|
|
62
|
+
font-family: var(--shell-font-ui);
|
|
63
|
+
font-size: 0.8125rem;
|
|
64
|
+
color: var(--shell-fg);
|
|
65
|
+
}
|
|
66
|
+
.sh3-field__label {
|
|
67
|
+
color: var(--shell-fg-muted);
|
|
68
|
+
font-size: 0.75rem;
|
|
69
|
+
}
|
|
70
|
+
.sh3-field__row {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: stretch;
|
|
73
|
+
background: var(--shell-input-bg);
|
|
74
|
+
border: 1px solid var(--shell-border);
|
|
75
|
+
border-radius: var(--shell-widget-radius);
|
|
76
|
+
height: var(--shell-field-height-md);
|
|
77
|
+
transition: border-color var(--shell-motion-fast) var(--shell-ease-standard),
|
|
78
|
+
box-shadow var(--shell-motion-fast) var(--shell-ease-standard);
|
|
79
|
+
}
|
|
80
|
+
.sh3-field--sm .sh3-field__row { height: var(--shell-field-height-sm); }
|
|
81
|
+
.sh3-field__row:focus-within {
|
|
82
|
+
border-color: var(--shell-input-border-focus);
|
|
83
|
+
box-shadow: var(--shell-focus-ring);
|
|
84
|
+
}
|
|
85
|
+
.sh3-field--invalid .sh3-field__row {
|
|
86
|
+
border-color: var(--shell-error);
|
|
87
|
+
}
|
|
88
|
+
.sh3-field--invalid .sh3-field__row:focus-within {
|
|
89
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-error) 40%, transparent);
|
|
90
|
+
}
|
|
91
|
+
.sh3-field__input {
|
|
92
|
+
flex: 1 1 auto;
|
|
93
|
+
height: 100%;
|
|
94
|
+
padding: 0 var(--shell-field-pad-x);
|
|
95
|
+
background: transparent;
|
|
96
|
+
border: none;
|
|
97
|
+
color: inherit;
|
|
98
|
+
font: inherit;
|
|
99
|
+
outline: none;
|
|
100
|
+
}
|
|
101
|
+
/* The .sh3-field__row owns the focus ring; suppress base.css's
|
|
102
|
+
global input:focus-visible box-shadow so it doesn't double up. */
|
|
103
|
+
.sh3-field__input:focus,
|
|
104
|
+
.sh3-field__input:focus-visible {
|
|
105
|
+
outline: none;
|
|
106
|
+
box-shadow: none;
|
|
107
|
+
border: none;
|
|
108
|
+
}
|
|
109
|
+
.sh3-field__input:disabled { color: var(--shell-fg-muted); cursor: not-allowed; }
|
|
110
|
+
.sh3-field__affix {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
padding: 0 var(--shell-field-pad-x);
|
|
114
|
+
color: var(--shell-fg-muted);
|
|
115
|
+
flex-shrink: 0;
|
|
116
|
+
}
|
|
117
|
+
.sh3-field__affix:first-child { padding-right: 0; }
|
|
118
|
+
.sh3-field__affix:last-child { padding-left: 0; }
|
|
119
|
+
.sh3-field__helper {
|
|
120
|
+
color: var(--shell-fg-muted);
|
|
121
|
+
font-size: 0.75rem;
|
|
122
|
+
}
|
|
123
|
+
.sh3-field__helper--error { color: var(--shell-error); }
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
value?: string;
|
|
4
|
+
type?: 'text' | 'email' | 'password' | 'search' | 'url' | 'tel';
|
|
5
|
+
label?: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
prefix?: Snippet;
|
|
8
|
+
suffix?: Snippet;
|
|
9
|
+
helper?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
size?: 'sm' | 'md';
|
|
14
|
+
required?: boolean;
|
|
15
|
+
autocomplete?: AutoFill;
|
|
16
|
+
};
|
|
17
|
+
declare const Field: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
18
|
+
type Field = ReturnType<typeof Field>;
|
|
19
|
+
export default Field;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function extractValue(files, multiple) {
|
|
2
|
+
if (!files || files.length === 0)
|
|
3
|
+
return multiple ? [] : null;
|
|
4
|
+
if (multiple)
|
|
5
|
+
return Array.from(files);
|
|
6
|
+
return files[0];
|
|
7
|
+
}
|
|
8
|
+
export function displayName(value) {
|
|
9
|
+
if (value === null)
|
|
10
|
+
return '';
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
if (value.length === 0)
|
|
13
|
+
return '';
|
|
14
|
+
if (value.length === 1)
|
|
15
|
+
return value[0].name;
|
|
16
|
+
return `${value.length} files`;
|
|
17
|
+
}
|
|
18
|
+
return value.name;
|
|
19
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { extractValue, displayName, type FilePickerValue } from './FilePicker';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable<FilePickerValue>(null),
|
|
6
|
+
multiple = false,
|
|
7
|
+
accept,
|
|
8
|
+
disabled = false,
|
|
9
|
+
invalid = false,
|
|
10
|
+
size = 'md',
|
|
11
|
+
buttonLabel = 'Choose file...',
|
|
12
|
+
}: {
|
|
13
|
+
value?: FilePickerValue;
|
|
14
|
+
multiple?: boolean;
|
|
15
|
+
accept?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
size?: 'sm' | 'md';
|
|
19
|
+
buttonLabel?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
function onChange(e: Event) {
|
|
23
|
+
const target = e.currentTarget as HTMLInputElement;
|
|
24
|
+
value = extractValue(target.files, multiple);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const display = $derived(displayName(value));
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<label class="sh3-fp" class:sh3-fp--sm={size === 'sm'} class:sh3-fp--invalid={invalid}>
|
|
31
|
+
<input
|
|
32
|
+
class="sh3-fp__native"
|
|
33
|
+
type="file"
|
|
34
|
+
{accept}
|
|
35
|
+
{multiple}
|
|
36
|
+
{disabled}
|
|
37
|
+
onchange={onChange}
|
|
38
|
+
/>
|
|
39
|
+
<span class="sh3-fp__btn" tabindex="-1">{buttonLabel}</span>
|
|
40
|
+
<span class="sh3-fp__name">{display || 'no file'}</span>
|
|
41
|
+
</label>
|
|
42
|
+
|
|
43
|
+
<style>
|
|
44
|
+
.sh3-fp {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: stretch;
|
|
47
|
+
height: var(--shell-field-height-md);
|
|
48
|
+
border: 1px solid var(--shell-border);
|
|
49
|
+
border-radius: var(--shell-widget-radius);
|
|
50
|
+
background: var(--shell-input-bg);
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
font-size: 0.8125rem;
|
|
54
|
+
}
|
|
55
|
+
.sh3-fp--sm { height: var(--shell-field-height-sm); }
|
|
56
|
+
.sh3-fp--invalid { border-color: var(--shell-error); }
|
|
57
|
+
.sh3-fp:focus-within { box-shadow: var(--shell-focus-ring); border-color: var(--shell-input-border-focus); }
|
|
58
|
+
|
|
59
|
+
.sh3-fp__native {
|
|
60
|
+
position: absolute;
|
|
61
|
+
width: 1px; height: 1px;
|
|
62
|
+
opacity: 0;
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
.sh3-fp__btn {
|
|
66
|
+
display: inline-flex; align-items: center;
|
|
67
|
+
padding: 0 var(--shell-field-pad-x);
|
|
68
|
+
background: var(--shell-bg-elevated);
|
|
69
|
+
color: var(--shell-fg);
|
|
70
|
+
border-right: 1px solid var(--shell-border);
|
|
71
|
+
}
|
|
72
|
+
.sh3-fp__name {
|
|
73
|
+
display: inline-flex; align-items: center;
|
|
74
|
+
padding: 0 var(--shell-field-pad-x);
|
|
75
|
+
color: var(--shell-fg-muted);
|
|
76
|
+
flex: 1; min-width: 0;
|
|
77
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type FilePickerValue } from './FilePicker';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
value?: FilePickerValue;
|
|
4
|
+
multiple?: boolean;
|
|
5
|
+
accept?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
invalid?: boolean;
|
|
8
|
+
size?: 'sm' | 'md';
|
|
9
|
+
buttonLabel?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const FilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
12
|
+
type FilePicker = ReturnType<typeof FilePicker>;
|
|
13
|
+
export default FilePicker;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractValue, displayName } from './FilePicker';
|
|
3
|
+
// Test stubs — File and DataTransfer aren't available in node test env, so
|
|
4
|
+
// we structurally fake FileList/File. The helpers only read `length`, indexed
|
|
5
|
+
// access, and `.name` so this is sufficient.
|
|
6
|
+
function mkFile(name) {
|
|
7
|
+
return { name };
|
|
8
|
+
}
|
|
9
|
+
function mkList(...files) {
|
|
10
|
+
const list = files;
|
|
11
|
+
Object.defineProperty(list, 'item', {
|
|
12
|
+
value: (i) => { var _a; return (_a = files[i]) !== null && _a !== void 0 ? _a : null; },
|
|
13
|
+
});
|
|
14
|
+
return list;
|
|
15
|
+
}
|
|
16
|
+
describe('FilePicker value extraction', () => {
|
|
17
|
+
it('returns null when FileList is empty (single)', () => {
|
|
18
|
+
expect(extractValue(mkList(), false)).toBe(null);
|
|
19
|
+
});
|
|
20
|
+
it('returns first File in single mode', () => {
|
|
21
|
+
const r = extractValue(mkList(mkFile('a.txt'), mkFile('b.txt')), false);
|
|
22
|
+
expect(r.name).toBe('a.txt');
|
|
23
|
+
});
|
|
24
|
+
it('returns array in multiple mode', () => {
|
|
25
|
+
const r = extractValue(mkList(mkFile('a.txt'), mkFile('b.txt')), true);
|
|
26
|
+
expect(r).toHaveLength(2);
|
|
27
|
+
expect(r[0].name).toBe('a.txt');
|
|
28
|
+
});
|
|
29
|
+
it('returns [] when FileList empty in multiple mode', () => {
|
|
30
|
+
expect(extractValue(null, true)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it('displayName: null → empty', () => {
|
|
33
|
+
expect(displayName(null)).toBe('');
|
|
34
|
+
});
|
|
35
|
+
it('displayName: single file → name', () => {
|
|
36
|
+
expect(displayName(mkFile('cat.png'))).toBe('cat.png');
|
|
37
|
+
});
|
|
38
|
+
it('displayName: multiple files → "N files"', () => {
|
|
39
|
+
expect(displayName([mkFile('a.txt'), mkFile('b.txt')])).toBe('2 files');
|
|
40
|
+
});
|
|
41
|
+
it('displayName: single in array → just the name', () => {
|
|
42
|
+
expect(displayName([mkFile('one.txt')])).toBe('one.txt');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function toggleSingle(current, candidate) {
|
|
2
|
+
return current === candidate ? '' : candidate;
|
|
3
|
+
}
|
|
4
|
+
export function toggleMultiple(current, candidate) {
|
|
5
|
+
if (current.includes(candidate))
|
|
6
|
+
return current.filter((v) => v !== candidate);
|
|
7
|
+
return [...current, candidate];
|
|
8
|
+
}
|