@wells-nelson/interactions 0.0.0-stage → 0.0.1

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 (54) hide show
  1. package/README.md +84 -2
  2. package/dist/ActiveList.svelte +168 -0
  3. package/dist/ActiveList.svelte.d.ts +54 -0
  4. package/dist/AddRow.svelte +69 -0
  5. package/dist/AddRow.svelte.d.ts +8 -0
  6. package/dist/Checkbox.svelte +65 -0
  7. package/dist/Checkbox.svelte.d.ts +9 -0
  8. package/dist/CollapsibleSection.svelte +225 -0
  9. package/dist/CollapsibleSection.svelte.d.ts +34 -0
  10. package/dist/Combobox.svelte +201 -0
  11. package/dist/Combobox.svelte.d.ts +22 -0
  12. package/dist/DevWipeButton.svelte +36 -0
  13. package/dist/DevWipeButton.svelte.d.ts +10 -0
  14. package/dist/SearchBar.svelte +60 -0
  15. package/dist/SearchBar.svelte.d.ts +8 -0
  16. package/dist/Segmented.svelte +96 -0
  17. package/dist/Segmented.svelte.d.ts +33 -0
  18. package/dist/TextField.svelte +42 -0
  19. package/dist/TextField.svelte.d.ts +10 -0
  20. package/dist/Toggle.svelte +72 -0
  21. package/dist/Toggle.svelte.d.ts +9 -0
  22. package/dist/UndoButton.svelte +41 -0
  23. package/dist/UndoButton.svelte.d.ts +8 -0
  24. package/dist/account/AccountPanel.svelte +71 -0
  25. package/dist/account/AccountPanel.svelte.d.ts +15 -0
  26. package/dist/account/ContainerEditModal.svelte +284 -0
  27. package/dist/account/ContainerEditModal.svelte.d.ts +27 -0
  28. package/dist/account/LocalItemsPrompt.svelte +83 -0
  29. package/dist/account/LocalItemsPrompt.svelte.d.ts +15 -0
  30. package/dist/account/ShareModal.svelte +297 -0
  31. package/dist/account/ShareModal.svelte.d.ts +27 -0
  32. package/dist/account/SignInCard.svelte +178 -0
  33. package/dist/account/SignInCard.svelte.d.ts +14 -0
  34. package/dist/account/SignOutCard.svelte +75 -0
  35. package/dist/account/SignOutCard.svelte.d.ts +13 -0
  36. package/dist/account/SyncStatusCard.svelte +134 -0
  37. package/dist/account/SyncStatusCard.svelte.d.ts +14 -0
  38. package/dist/account/index.d.ts +8 -0
  39. package/dist/account/index.js +8 -0
  40. package/dist/account/types.d.ts +64 -0
  41. package/dist/account/types.js +17 -0
  42. package/dist/dnd.d.ts +32 -0
  43. package/dist/dnd.js +237 -0
  44. package/dist/index.d.ts +16 -0
  45. package/dist/index.js +16 -0
  46. package/dist/longpress.d.ts +11 -0
  47. package/dist/longpress.js +63 -0
  48. package/dist/press.d.ts +18 -0
  49. package/dist/press.js +36 -0
  50. package/dist/undo.svelte.d.ts +25 -0
  51. package/dist/undo.svelte.js +53 -0
  52. package/dist/wipe.d.ts +15 -0
  53. package/dist/wipe.js +24 -0
  54. package/package.json +49 -4
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import SignInCard from './SignInCard.svelte';
3
+ import SignOutCard from './SignOutCard.svelte';
4
+ import SyncStatusCard from './SyncStatusCard.svelte';
5
+ import { capitalize, type AccountActions, type CloudStatus, type Nouns } from './types.js';
6
+
7
+ /**
8
+ * The whole profile page body: "not configured", sign-in, or the signed-in summary with sync
9
+ * status and sign-out. Use the cards directly for a different layout.
10
+ */
11
+ let {
12
+ cloud,
13
+ account,
14
+ nouns,
15
+ appName,
16
+ returnUrl,
17
+ pendingName,
18
+ emailLink = true,
19
+ signInPitch
20
+ }: {
21
+ cloud: CloudStatus;
22
+ account: AccountActions;
23
+ nouns: Nouns;
24
+ appName: string;
25
+ returnUrl: () => string;
26
+ /** Display name of a container with pending changes (it may already be deleted). */
27
+ pendingName: (id: string) => string;
28
+ emailLink?: boolean;
29
+ signInPitch?: string;
30
+ } = $props();
31
+
32
+ const pending = $derived(cloud.pendingIds.map((id) => ({ id, name: pendingName(id) })));
33
+ const pitch = $derived(
34
+ signInPitch ??
35
+ `Sign in to sync ${nouns.plural} across your devices and share them with others. ` +
36
+ `${capitalize(nouns.plural)} you make while signed out stay local-only on this device.`
37
+ );
38
+ </script>
39
+
40
+ {#if !cloud.configured}
41
+ <section class="card section">
42
+ <h2>Cloud sync isn't set up</h2>
43
+ <p class="muted">
44
+ This copy of {appName} runs local-only: every {nouns.singular} stays on this device. Signing in becomes available once
45
+ Supabase is configured (see the README).
46
+ </p>
47
+ </section>
48
+ {:else if !cloud.user || cloud.sessionExpired}
49
+ <SignInCard {cloud} {account} {returnUrl} {pitch} {emailLink} />
50
+ {:else}
51
+ <SyncStatusCard {cloud} {account} {nouns} {pending} />
52
+ <SignOutCard {cloud} {account} {nouns} {pending} />
53
+ {/if}
54
+
55
+ <style>
56
+ .section {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 0.75rem;
60
+ padding: 1rem;
61
+ margin-bottom: 1rem;
62
+ }
63
+ .section p {
64
+ margin: 0;
65
+ line-height: 1.5;
66
+ }
67
+ h2 {
68
+ margin: 0;
69
+ font-size: 1.05rem;
70
+ }
71
+ </style>
@@ -0,0 +1,15 @@
1
+ import { type AccountActions, type CloudStatus, type Nouns } from './types.js';
2
+ type $$ComponentProps = {
3
+ cloud: CloudStatus;
4
+ account: AccountActions;
5
+ nouns: Nouns;
6
+ appName: string;
7
+ returnUrl: () => string;
8
+ /** Display name of a container with pending changes (it may already be deleted). */
9
+ pendingName: (id: string) => string;
10
+ emailLink?: boolean;
11
+ signInPitch?: string;
12
+ };
13
+ declare const AccountPanel: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type AccountPanel = ReturnType<typeof AccountPanel>;
15
+ export default AccountPanel;
@@ -0,0 +1,284 @@
1
+ <script lang="ts">
2
+ import { Button, ConfirmDialog, Icon, Modal } from '@wells-nelson/displays';
3
+ import { untrack } from 'svelte';
4
+ import TextField from '../TextField.svelte';
5
+ import Toggle from '../Toggle.svelte';
6
+ import {
7
+ capitalize,
8
+ errorText,
9
+ type CloudStatus,
10
+ type ContainerLocation,
11
+ type Member,
12
+ type MembersApi,
13
+ type Nouns,
14
+ type Role
15
+ } from './types.js';
16
+
17
+ /**
18
+ * The pencil menu of a container: rename, "open when the app starts", cloud-enabled toggle
19
+ * (with a disconnect warning), and delete or leave. A shared cloud container is left rather
20
+ * than deleted while other owners remain (without roles every member counts as an owner).
21
+ */
22
+ let {
23
+ containerId,
24
+ name: currentName,
25
+ location,
26
+ ready = true,
27
+ role = 'owner',
28
+ roles = false,
29
+ cloud,
30
+ members: api,
31
+ nouns,
32
+ signInHref,
33
+ contentsLabel = 'everything in it',
34
+ isDefault,
35
+ ondefaultchange,
36
+ onrename,
37
+ onmakecloud,
38
+ onmakelocal,
39
+ ondelete,
40
+ onleave,
41
+ onclose
42
+ }: {
43
+ containerId: string;
44
+ name: string;
45
+ /** Undefined once the container is gone (deleted, or moved under a new id); the modal closes. */
46
+ location: ContainerLocation | undefined;
47
+ ready?: boolean;
48
+ role?: Role;
49
+ roles?: boolean;
50
+ cloud: CloudStatus;
51
+ members: Pick<MembersApi, 'fetchMembers'>;
52
+ nouns: Nouns;
53
+ signInHref: string;
54
+ /** What gets deleted along with it, e.g. "all its tasks and notes". */
55
+ contentsLabel?: string;
56
+ isDefault: boolean;
57
+ ondefaultchange: (isDefault: boolean) => void;
58
+ onrename: (name: string) => void;
59
+ onmakecloud: () => Promise<unknown>;
60
+ onmakelocal: () => Promise<unknown>;
61
+ ondelete: () => Promise<unknown>;
62
+ onleave: () => Promise<unknown>;
63
+ onclose: () => void;
64
+ } = $props();
65
+
66
+ const Singular = $derived(capitalize(nouns.singular));
67
+ const isCloud = $derived(location === 'cloud');
68
+ const isOwner = $derived(!roles || role === 'owner');
69
+ const signedIn = $derived(!!cloud.user);
70
+
71
+ let open = $state(true);
72
+ // svelte-ignore state_referenced_locally
73
+ let name = $state(currentName);
74
+ let error = $state<string | null>(null);
75
+ let busy = $state(false);
76
+
77
+ function close() {
78
+ open = false;
79
+ onclose();
80
+ }
81
+
82
+ $effect(() => {
83
+ if (ready && !location && !busy) close();
84
+ });
85
+
86
+ function save(e: SubmitEvent) {
87
+ e.preventDefault();
88
+ const next = name.trim();
89
+ if (location && isOwner && next && next !== currentName) onrename(next);
90
+ close();
91
+ }
92
+
93
+ // ------------------------------------------------ members (decides delete vs. leave)
94
+ let list = $state<Member[] | null>(null);
95
+ const others = $derived((list ?? []).filter((m) => m.email !== cloud.email));
96
+ const otherOwners = $derived(others.filter((m) => !roles || m.role === 'owner').length);
97
+
98
+ $effect(() => {
99
+ if (!isCloud || !signedIn || !cloud.online) return;
100
+ // Only the conditions above should re-run this, not state the API happens to read.
101
+ untrack(() =>
102
+ api
103
+ .fetchMembers(containerId)
104
+ .then((m) => (list = m))
105
+ .catch(() => (list = null))
106
+ );
107
+ });
108
+
109
+ // ------------------------------------------------ cloud / local
110
+ let confirmLocal = $state(false);
111
+ let cloudToggle = $state(false);
112
+ // Mirror the real state whenever no swap is pending (cancelled or failed swaps snap back).
113
+ $effect(() => {
114
+ if (!confirmLocal && !busy) cloudToggle = isCloud;
115
+ });
116
+
117
+ async function run(fn: () => Promise<unknown>) {
118
+ busy = true;
119
+ error = null;
120
+ try {
121
+ await fn();
122
+ } catch (err) {
123
+ error = errorText(err);
124
+ } finally {
125
+ busy = false;
126
+ }
127
+ }
128
+
129
+ const localMessage = $derived(
130
+ `This disconnects “${currentName}” from the copies on your other devices` +
131
+ (others.length > 0 ? ' and from everyone it’s shared with' : '') +
132
+ '. From then on, changes made here won’t reach them and theirs won’t reach you.\n\n' +
133
+ (others.length > 0
134
+ ? `Because it’s shared, the cloud ${nouns.singular} stays for the others; you get a separate local-only copy and leave it.`
135
+ : 'The cloud copy is deleted, so it also disappears from your other devices. This device keeps it.')
136
+ );
137
+
138
+ // ------------------------------------------------ delete / leave
139
+ let confirmDelete = $state(false);
140
+ let confirmLeave = $state(false);
141
+ /** Non-owners can only leave; owners leave instead of deleting while other owners remain. */
142
+ const leaveOnly = $derived(isCloud && (!isOwner || otherOwners > 0));
143
+
144
+ const doDelete = () =>
145
+ run(async () => {
146
+ await ondelete();
147
+ close();
148
+ });
149
+ const doLeave = () =>
150
+ run(async () => {
151
+ await onleave();
152
+ close();
153
+ });
154
+ </script>
155
+
156
+ <Modal bind:open title={`Edit ${nouns.singular}`} {onclose}>
157
+ <form id={`edit-${containerId}`} onsubmit={save}>
158
+ {#if isOwner}
159
+ <TextField label={`${Singular} name`} bind:value={name} enterkeyhint="done" />
160
+ {:else}
161
+ <p class="muted small">
162
+ You're {role === 'editor' ? 'an editor' : 'a viewer'} on “{currentName}”. Only owners can rename it.
163
+ </p>
164
+ {/if}
165
+ </form>
166
+
167
+ <Toggle
168
+ label={`Open this ${nouns.singular} when the app starts`}
169
+ checked={isDefault}
170
+ onchange={(on) => ondefaultchange(on)}
171
+ />
172
+
173
+ <section class="group">
174
+ <h3>Sync</h3>
175
+ {#if signedIn}
176
+ <Toggle
177
+ label="Cloud-enabled"
178
+ bind:checked={cloudToggle}
179
+ disabled={busy}
180
+ onchange={(on) => (on ? run(onmakecloud) : (confirmLocal = true))}
181
+ />
182
+ <p class="muted small">
183
+ {#if isCloud}
184
+ Synced to your account{cloud.hasPending(containerId) ? ' — changes are waiting to upload' : ''}.
185
+ {:else}
186
+ Only on this device. Turn on to sync{cloud.online ? '' : ' (uploads once you are back online)'}.
187
+ {/if}
188
+ </p>
189
+ {:else}
190
+ <p class="muted small">
191
+ <Icon name="wifi-off" size={16} /> Local-only. <a href={signInHref}>Sign in</a> to sync and share {nouns.plural}.
192
+ </p>
193
+ {/if}
194
+ </section>
195
+
196
+ {#if error}<p class="error">{error}</p>{/if}
197
+
198
+ {#if leaveOnly}
199
+ <Button
200
+ variant="ghost"
201
+ onclick={() => (confirmLeave = true)}
202
+ disabled={busy || !cloud.online}
203
+ style="align-self:flex-start;color:var(--danger)"
204
+ >
205
+ Leave {nouns.singular}
206
+ </Button>
207
+ {#if isOwner}
208
+ <p class="muted small">
209
+ {roles ? 'Other owners remain' : 'It’s shared'}, so the {nouns.singular} stays for them.
210
+ </p>
211
+ {/if}
212
+ {:else}
213
+ <Button
214
+ variant="ghost"
215
+ icon="trash"
216
+ onclick={() => (confirmDelete = true)}
217
+ disabled={busy || (isCloud && !cloud.online)}
218
+ style="align-self:flex-start;color:var(--danger)"
219
+ >
220
+ Delete {nouns.singular}
221
+ </Button>
222
+ {/if}
223
+ {#if isCloud && !cloud.online}
224
+ <p class="muted small">Reconnect to delete or leave a cloud-enabled {nouns.singular}.</p>
225
+ {/if}
226
+
227
+ {#snippet footer()}
228
+ <Button onclick={close}>Cancel</Button>
229
+ <Button variant="primary" type="submit" form={`edit-${containerId}`}>Save</Button>
230
+ {/snippet}
231
+ </Modal>
232
+
233
+ <ConfirmDialog
234
+ bind:open={confirmLocal}
235
+ title={`Disconnect this ${nouns.singular}?`}
236
+ message={localMessage}
237
+ confirmLabel="Make local-only"
238
+ danger
239
+ onconfirm={() => run(onmakelocal)}
240
+ />
241
+
242
+ <ConfirmDialog
243
+ bind:open={confirmDelete}
244
+ title={`Delete this ${nouns.singular}?`}
245
+ message={`“${currentName}” and ${contentsLabel} will be permanently deleted${others.length ? ' for everyone it is shared with' : ''}.`}
246
+ confirmLabel={`Delete ${nouns.singular}`}
247
+ danger
248
+ onconfirm={doDelete}
249
+ />
250
+
251
+ <ConfirmDialog
252
+ bind:open={confirmLeave}
253
+ title={`Leave this ${nouns.singular}?`}
254
+ message={`“${currentName}” is shared, so it stays for the others. It will be removed from your account and this device.` +
255
+ (roles ? ' Ask an owner to share it again if you need it back.' : '')}
256
+ confirmLabel={`Leave ${nouns.singular}`}
257
+ danger
258
+ onconfirm={doLeave}
259
+ />
260
+
261
+ <style>
262
+ .group {
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: 0.5rem;
266
+ }
267
+ h3 {
268
+ margin: 0;
269
+ font-size: 0.95rem;
270
+ }
271
+ .small {
272
+ margin: 0;
273
+ font-size: 0.9rem;
274
+ line-height: 1.45;
275
+ }
276
+ .small :global(.icon) {
277
+ display: inline;
278
+ vertical-align: -3px;
279
+ }
280
+ .error {
281
+ margin: 0;
282
+ color: var(--danger);
283
+ }
284
+ </style>
@@ -0,0 +1,27 @@
1
+ import { type CloudStatus, type ContainerLocation, type MembersApi, type Nouns, type Role } from './types.js';
2
+ type $$ComponentProps = {
3
+ containerId: string;
4
+ name: string;
5
+ /** Undefined once the container is gone (deleted, or moved under a new id); the modal closes. */
6
+ location: ContainerLocation | undefined;
7
+ ready?: boolean;
8
+ role?: Role;
9
+ roles?: boolean;
10
+ cloud: CloudStatus;
11
+ members: Pick<MembersApi, 'fetchMembers'>;
12
+ nouns: Nouns;
13
+ signInHref: string;
14
+ /** What gets deleted along with it, e.g. "all its tasks and notes". */
15
+ contentsLabel?: string;
16
+ isDefault: boolean;
17
+ ondefaultchange: (isDefault: boolean) => void;
18
+ onrename: (name: string) => void;
19
+ onmakecloud: () => Promise<unknown>;
20
+ onmakelocal: () => Promise<unknown>;
21
+ ondelete: () => Promise<unknown>;
22
+ onleave: () => Promise<unknown>;
23
+ onclose: () => void;
24
+ };
25
+ declare const ContainerEditModal: import("svelte").Component<$$ComponentProps, {}, "">;
26
+ type ContainerEditModal = ReturnType<typeof ContainerEditModal>;
27
+ export default ContainerEditModal;
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import { Button, Modal } from '@wells-nelson/displays';
3
+ import Checkbox from '../Checkbox.svelte';
4
+ import { errorText, type Nouns } from './types.js';
5
+
6
+ /**
7
+ * Shown after sign-in for local-only containers that were never offered before. Whatever isn't
8
+ * checked is never asked about again (the container's edit menu can still switch it).
9
+ */
10
+ let {
11
+ items,
12
+ nouns,
13
+ onmakecloud,
14
+ onprompted
15
+ }: {
16
+ items: { id: string; name: string }[];
17
+ nouns: Nouns;
18
+ /** Make one container cloud-enabled. */
19
+ onmakecloud: (id: string) => Promise<unknown>;
20
+ /** Remember these were offered (checked or not) so they aren't asked about again. */
21
+ onprompted: (ids: string[]) => void;
22
+ } = $props();
23
+
24
+ let open = $state(true);
25
+ let selected = $state<Record<string, boolean>>({});
26
+ let busy = $state(false);
27
+ let error = $state<string | null>(null);
28
+
29
+ async function submit() {
30
+ busy = true;
31
+ error = null;
32
+ try {
33
+ for (const item of items) if (selected[item.id]) await onmakecloud(item.id);
34
+ onprompted(items.map((i) => i.id));
35
+ open = false;
36
+ } catch (err) {
37
+ error = errorText(err);
38
+ } finally {
39
+ busy = false;
40
+ }
41
+ }
42
+
43
+ function skip() {
44
+ onprompted(items.map((i) => i.id));
45
+ open = false;
46
+ }
47
+ </script>
48
+
49
+ <Modal bind:open title={`Sync your local ${nouns.plural}?`} onclose={skip}>
50
+ <p class="intro">
51
+ These {nouns.plural} are only on this device. Check any you want cloud-enabled so they sync across your devices and
52
+ can be shared.
53
+ </p>
54
+ <div class="items">
55
+ {#each items as item (item.id)}
56
+ <!-- Unchecked rows have no entry; bind:checked can't take undefined for a prop with a fallback. -->
57
+ <Checkbox
58
+ label={item.name}
59
+ bind:checked={() => selected[item.id] ?? false, (on) => (selected[item.id] = on)}
60
+ />
61
+ {/each}
62
+ </div>
63
+ {#if error}<p class="error">{error}</p>{/if}
64
+ {#snippet footer()}
65
+ <Button onclick={skip} disabled={busy}>Not now</Button>
66
+ <Button variant="primary" onclick={submit} disabled={busy}>Make selected cloud-enabled</Button>
67
+ {/snippet}
68
+ </Modal>
69
+
70
+ <style>
71
+ .intro {
72
+ margin: 0;
73
+ line-height: 1.5;
74
+ }
75
+ .items {
76
+ display: flex;
77
+ flex-direction: column;
78
+ }
79
+ .error {
80
+ color: var(--danger);
81
+ margin: 0;
82
+ }
83
+ </style>
@@ -0,0 +1,15 @@
1
+ import { type Nouns } from './types.js';
2
+ type $$ComponentProps = {
3
+ items: {
4
+ id: string;
5
+ name: string;
6
+ }[];
7
+ nouns: Nouns;
8
+ /** Make one container cloud-enabled. */
9
+ onmakecloud: (id: string) => Promise<unknown>;
10
+ /** Remember these were offered (checked or not) so they aren't asked about again. */
11
+ onprompted: (ids: string[]) => void;
12
+ };
13
+ declare const LocalItemsPrompt: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type LocalItemsPrompt = ReturnType<typeof LocalItemsPrompt>;
15
+ export default LocalItemsPrompt;