@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.
- package/README.md +84 -2
- package/dist/ActiveList.svelte +168 -0
- package/dist/ActiveList.svelte.d.ts +54 -0
- package/dist/AddRow.svelte +69 -0
- package/dist/AddRow.svelte.d.ts +8 -0
- package/dist/Checkbox.svelte +65 -0
- package/dist/Checkbox.svelte.d.ts +9 -0
- package/dist/CollapsibleSection.svelte +225 -0
- package/dist/CollapsibleSection.svelte.d.ts +34 -0
- package/dist/Combobox.svelte +201 -0
- package/dist/Combobox.svelte.d.ts +22 -0
- package/dist/DevWipeButton.svelte +36 -0
- package/dist/DevWipeButton.svelte.d.ts +10 -0
- package/dist/SearchBar.svelte +60 -0
- package/dist/SearchBar.svelte.d.ts +8 -0
- package/dist/Segmented.svelte +96 -0
- package/dist/Segmented.svelte.d.ts +33 -0
- package/dist/TextField.svelte +42 -0
- package/dist/TextField.svelte.d.ts +10 -0
- package/dist/Toggle.svelte +72 -0
- package/dist/Toggle.svelte.d.ts +9 -0
- package/dist/UndoButton.svelte +41 -0
- package/dist/UndoButton.svelte.d.ts +8 -0
- package/dist/account/AccountPanel.svelte +71 -0
- package/dist/account/AccountPanel.svelte.d.ts +15 -0
- package/dist/account/ContainerEditModal.svelte +284 -0
- package/dist/account/ContainerEditModal.svelte.d.ts +27 -0
- package/dist/account/LocalItemsPrompt.svelte +83 -0
- package/dist/account/LocalItemsPrompt.svelte.d.ts +15 -0
- package/dist/account/ShareModal.svelte +297 -0
- package/dist/account/ShareModal.svelte.d.ts +27 -0
- package/dist/account/SignInCard.svelte +178 -0
- package/dist/account/SignInCard.svelte.d.ts +14 -0
- package/dist/account/SignOutCard.svelte +75 -0
- package/dist/account/SignOutCard.svelte.d.ts +13 -0
- package/dist/account/SyncStatusCard.svelte +134 -0
- package/dist/account/SyncStatusCard.svelte.d.ts +14 -0
- package/dist/account/index.d.ts +8 -0
- package/dist/account/index.js +8 -0
- package/dist/account/types.d.ts +64 -0
- package/dist/account/types.js +17 -0
- package/dist/dnd.d.ts +32 -0
- package/dist/dnd.js +237 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/longpress.d.ts +11 -0
- package/dist/longpress.js +63 -0
- package/dist/press.d.ts +18 -0
- package/dist/press.js +36 -0
- package/dist/undo.svelte.d.ts +25 -0
- package/dist/undo.svelte.js +53 -0
- package/dist/wipe.d.ts +15 -0
- package/dist/wipe.js +24 -0
- package/package.json +49 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Icon } from '@wells-nelson/displays';
|
|
3
|
+
import { capitalize, type AccountActions, type CloudStatus, type Nouns } from './types.js';
|
|
4
|
+
|
|
5
|
+
/** Signed-in summary: who, connection, last sync, what's waiting to upload, force re-sync. */
|
|
6
|
+
let {
|
|
7
|
+
cloud,
|
|
8
|
+
account,
|
|
9
|
+
nouns,
|
|
10
|
+
pending
|
|
11
|
+
}: {
|
|
12
|
+
cloud: CloudStatus;
|
|
13
|
+
account: Pick<AccountActions, 'forceResync'>;
|
|
14
|
+
nouns: Nouns;
|
|
15
|
+
/** Containers with unsynced changes, already named by the app. */
|
|
16
|
+
pending: { id: string; name: string }[];
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
let resyncing = $state(false);
|
|
20
|
+
const user = $derived(cloud.user);
|
|
21
|
+
const meta = $derived((user?.user_metadata ?? {}) as Record<string, string | undefined>);
|
|
22
|
+
const displayName = $derived(meta.full_name ?? meta.name ?? user?.email);
|
|
23
|
+
const lastSync = $derived(cloud.lastSync ? new Date(cloud.lastSync).toLocaleString() : 'Not yet');
|
|
24
|
+
|
|
25
|
+
async function resync() {
|
|
26
|
+
resyncing = true;
|
|
27
|
+
try {
|
|
28
|
+
await account.forceResync();
|
|
29
|
+
} finally {
|
|
30
|
+
setTimeout(() => (resyncing = false), 600);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<section class="card section">
|
|
36
|
+
<div class="who">
|
|
37
|
+
{#if meta.avatar_url}
|
|
38
|
+
<img src={meta.avatar_url} alt="" referrerpolicy="no-referrer" />
|
|
39
|
+
{/if}
|
|
40
|
+
<div>
|
|
41
|
+
<div class="name">{displayName}</div>
|
|
42
|
+
<div class="muted small">{user?.email}</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<section class="card section">
|
|
48
|
+
<h2>Sync</h2>
|
|
49
|
+
<dl class="status">
|
|
50
|
+
<dt>Connection</dt>
|
|
51
|
+
<dd>{cloud.online ? (cloud.active ? 'Online · syncing…' : 'Online') : 'Offline'}</dd>
|
|
52
|
+
<dt>Last synced</dt>
|
|
53
|
+
<dd>{lastSync}</dd>
|
|
54
|
+
</dl>
|
|
55
|
+
{#if pending.length > 0}
|
|
56
|
+
<div>
|
|
57
|
+
<p class="small"><strong>{capitalize(nouns.plural)} with changes waiting to sync</strong></p>
|
|
58
|
+
<ul class="pending">
|
|
59
|
+
{#each pending as p (p.id)}
|
|
60
|
+
<li><Icon name="cloud-pending" size={16} /> {p.name}</li>
|
|
61
|
+
{/each}
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|
|
64
|
+
{:else}
|
|
65
|
+
<p class="muted small">Everything is synced.</p>
|
|
66
|
+
{/if}
|
|
67
|
+
{#if cloud.error}<p class="error small">Last sync error: {cloud.error}</p>{/if}
|
|
68
|
+
<Button icon="refresh" onclick={resync} disabled={!cloud.online || resyncing}>
|
|
69
|
+
{resyncing ? 'Syncing…' : 'Force re-sync'}
|
|
70
|
+
</Button>
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.section {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
gap: 0.75rem;
|
|
78
|
+
padding: 1rem;
|
|
79
|
+
margin-bottom: 1rem;
|
|
80
|
+
}
|
|
81
|
+
.section p {
|
|
82
|
+
margin: 0;
|
|
83
|
+
line-height: 1.5;
|
|
84
|
+
}
|
|
85
|
+
h2 {
|
|
86
|
+
margin: 0;
|
|
87
|
+
font-size: 1.05rem;
|
|
88
|
+
}
|
|
89
|
+
.small {
|
|
90
|
+
font-size: 0.9rem;
|
|
91
|
+
}
|
|
92
|
+
.who {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
.who img {
|
|
98
|
+
width: 3rem;
|
|
99
|
+
height: 3rem;
|
|
100
|
+
border-radius: 50%;
|
|
101
|
+
}
|
|
102
|
+
.name {
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
font-size: 1.1rem;
|
|
105
|
+
}
|
|
106
|
+
.status {
|
|
107
|
+
display: grid;
|
|
108
|
+
grid-template-columns: max-content 1fr;
|
|
109
|
+
gap: 0.3rem 1rem;
|
|
110
|
+
margin: 0;
|
|
111
|
+
}
|
|
112
|
+
dt {
|
|
113
|
+
color: var(--muted);
|
|
114
|
+
}
|
|
115
|
+
dd {
|
|
116
|
+
margin: 0;
|
|
117
|
+
}
|
|
118
|
+
.pending {
|
|
119
|
+
list-style: none;
|
|
120
|
+
margin: 0.4rem 0 0;
|
|
121
|
+
padding: 0;
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
gap: 0.25rem;
|
|
125
|
+
}
|
|
126
|
+
.pending li {
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 0.4rem;
|
|
130
|
+
}
|
|
131
|
+
.error {
|
|
132
|
+
color: var(--danger);
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AccountActions, type CloudStatus, type Nouns } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
cloud: CloudStatus;
|
|
4
|
+
account: Pick<AccountActions, 'forceResync'>;
|
|
5
|
+
nouns: Nouns;
|
|
6
|
+
/** Containers with unsynced changes, already named by the app. */
|
|
7
|
+
pending: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
};
|
|
12
|
+
declare const SyncStatusCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type SyncStatusCard = ReturnType<typeof SyncStatusCard>;
|
|
14
|
+
export default SyncStatusCard;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as AccountPanel } from './AccountPanel.svelte';
|
|
2
|
+
export { default as ContainerEditModal } from './ContainerEditModal.svelte';
|
|
3
|
+
export { default as LocalItemsPrompt } from './LocalItemsPrompt.svelte';
|
|
4
|
+
export { default as ShareModal } from './ShareModal.svelte';
|
|
5
|
+
export { default as SignInCard } from './SignInCard.svelte';
|
|
6
|
+
export { default as SignOutCard } from './SignOutCard.svelte';
|
|
7
|
+
export { default as SyncStatusCard } from './SyncStatusCard.svelte';
|
|
8
|
+
export { EMAIL_PATTERN, ROLE_OPTIONS, capitalize, type AccountActions, type AccountUser, type CloudStatus, type ContainerLocation, type Member, type MembersApi, type Nouns, type Role } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as AccountPanel } from './AccountPanel.svelte';
|
|
2
|
+
export { default as ContainerEditModal } from './ContainerEditModal.svelte';
|
|
3
|
+
export { default as LocalItemsPrompt } from './LocalItemsPrompt.svelte';
|
|
4
|
+
export { default as ShareModal } from './ShareModal.svelte';
|
|
5
|
+
export { default as SignInCard } from './SignInCard.svelte';
|
|
6
|
+
export { default as SignOutCard } from './SignOutCard.svelte';
|
|
7
|
+
export { default as SyncStatusCard } from './SyncStatusCard.svelte';
|
|
8
|
+
export { EMAIL_PATTERN, ROLE_OPTIONS, capitalize } from './types.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the account and sharing components need from the app's sync layer. These are plain
|
|
3
|
+
* interfaces so this package has no storage dependency; `@wells-nelson/data-storage`'s
|
|
4
|
+
* `CloudSync` and members API satisfy them as they are.
|
|
5
|
+
*/
|
|
6
|
+
export type Role = 'viewer' | 'editor' | 'owner';
|
|
7
|
+
/** Where a container (project, list…) lives: only on this device, or synced to the cloud. */
|
|
8
|
+
export type ContainerLocation = 'local' | 'cloud';
|
|
9
|
+
/** Lowercase nouns for the app's container, e.g. `{ singular: 'project', plural: 'projects' }`. */
|
|
10
|
+
export interface Nouns {
|
|
11
|
+
singular: string;
|
|
12
|
+
plural: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AccountUser {
|
|
15
|
+
email?: string;
|
|
16
|
+
user_metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
/** Read-only sync status. */
|
|
19
|
+
export interface CloudStatus {
|
|
20
|
+
/** False when the app runs local-only (no Supabase settings). */
|
|
21
|
+
readonly configured: boolean;
|
|
22
|
+
readonly user: AccountUser | null;
|
|
23
|
+
/** The signed-in email, lowercased ('' when signed out). */
|
|
24
|
+
readonly email: string;
|
|
25
|
+
readonly online: boolean;
|
|
26
|
+
/** A replication is transferring right now. */
|
|
27
|
+
readonly active: boolean;
|
|
28
|
+
/** Supabase dropped the session on its own; data is kept until the user signs in again. */
|
|
29
|
+
readonly sessionExpired: boolean;
|
|
30
|
+
readonly error: string | null;
|
|
31
|
+
readonly lastSync: number | null;
|
|
32
|
+
readonly hasAnyPending: boolean;
|
|
33
|
+
/** Containers with local changes that haven't reached the cloud. */
|
|
34
|
+
readonly pendingIds: string[];
|
|
35
|
+
hasPending(containerId: string): boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface AccountActions {
|
|
38
|
+
signInWithGoogle(redirectTo: string): Promise<void>;
|
|
39
|
+
/** Emails a magic sign-in link. */
|
|
40
|
+
signInWithEmail(email: string, redirectTo: string): Promise<void>;
|
|
41
|
+
signOut(): Promise<void>;
|
|
42
|
+
forceResync(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface Member {
|
|
45
|
+
id: string;
|
|
46
|
+
email: string;
|
|
47
|
+
/** True when the email belongs to a registered user; false means the invite is pending. */
|
|
48
|
+
active: boolean;
|
|
49
|
+
/** Present when the app uses roles. */
|
|
50
|
+
role?: Role | null;
|
|
51
|
+
}
|
|
52
|
+
export interface MembersApi {
|
|
53
|
+
fetchMembers(containerId: string): Promise<Member[]>;
|
|
54
|
+
inviteMember(containerId: string, email: string, role?: Role): Promise<void>;
|
|
55
|
+
removeMember(memberId: string): Promise<void>;
|
|
56
|
+
setMemberRole?(memberId: string, role: Role): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export declare const ROLE_OPTIONS: {
|
|
59
|
+
value: Role;
|
|
60
|
+
label: string;
|
|
61
|
+
}[];
|
|
62
|
+
export declare const EMAIL_PATTERN: RegExp;
|
|
63
|
+
export declare function capitalize(word: string): string;
|
|
64
|
+
export declare function errorText(err: unknown): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the account and sharing components need from the app's sync layer. These are plain
|
|
3
|
+
* interfaces so this package has no storage dependency; `@wells-nelson/data-storage`'s
|
|
4
|
+
* `CloudSync` and members API satisfy them as they are.
|
|
5
|
+
*/
|
|
6
|
+
export const ROLE_OPTIONS = [
|
|
7
|
+
{ value: 'viewer', label: 'Viewer' },
|
|
8
|
+
{ value: 'editor', label: 'Editor' },
|
|
9
|
+
{ value: 'owner', label: 'Owner' }
|
|
10
|
+
];
|
|
11
|
+
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
12
|
+
export function capitalize(word) {
|
|
13
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
14
|
+
}
|
|
15
|
+
export function errorText(err) {
|
|
16
|
+
return err instanceof Error ? err.message : String(err);
|
|
17
|
+
}
|
package/dist/dnd.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-press drag and drop that works for touch and mouse.
|
|
3
|
+
*
|
|
4
|
+
* - `draggable` starts a drag after holding still for DRAG_PRESS_MS (the shared long-press
|
|
5
|
+
* detection in press.ts); moving earlier cancels it, so normal scrolling still works.
|
|
6
|
+
* - `dropZone` marks an element that accepts drops. Ordered zones report the insertion index
|
|
7
|
+
* among their `[data-dnd-item]` children (the dragged element excluded).
|
|
8
|
+
*/
|
|
9
|
+
import type { Action } from 'svelte/action';
|
|
10
|
+
export interface DragData {
|
|
11
|
+
type: string;
|
|
12
|
+
id: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface DraggableOptions {
|
|
16
|
+
data: DragData;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
onstart?: (data: DragData) => void;
|
|
19
|
+
onend?: (data: DragData) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface DropZoneOptions {
|
|
22
|
+
accepts: (data: DragData) => boolean;
|
|
23
|
+
ondrop: (data: DragData, index: number) => void;
|
|
24
|
+
/** Show an insertion line and compute an index (reordering) instead of highlighting the zone. */
|
|
25
|
+
ordered?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare const DRAG_PRESS_MS = 300;
|
|
28
|
+
export declare function isDragging(): boolean;
|
|
29
|
+
export declare const draggable: Action<HTMLElement, DraggableOptions>;
|
|
30
|
+
export declare const dropZone: Action<HTMLElement, DropZoneOptions>;
|
|
31
|
+
/** Move `id` to `index` within `ids` (index counted without `id`, as reported by ordered zones). */
|
|
32
|
+
export declare function reorder(ids: string[], id: string, index: number): string[];
|
package/dist/dnd.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { watchPress } from './press.js';
|
|
2
|
+
export const DRAG_PRESS_MS = 300;
|
|
3
|
+
const MOVE_TOLERANCE = 8;
|
|
4
|
+
const EDGE = 72;
|
|
5
|
+
const zones = new Map();
|
|
6
|
+
let active = null;
|
|
7
|
+
export function isDragging() {
|
|
8
|
+
return active !== null;
|
|
9
|
+
}
|
|
10
|
+
function findZone(x, y, data) {
|
|
11
|
+
for (const el of document.elementsFromPoint(x, y)) {
|
|
12
|
+
let node = el;
|
|
13
|
+
while (node) {
|
|
14
|
+
if (node instanceof HTMLElement) {
|
|
15
|
+
const opts = zones.get(node);
|
|
16
|
+
if (opts?.accepts(data))
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
node = node.parentElement;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function zoneItems(zone, source) {
|
|
25
|
+
return [...zone.querySelectorAll(':scope > [data-dnd-item]')].filter((el) => el !== source && !el.contains(source));
|
|
26
|
+
}
|
|
27
|
+
function updateTarget(drag) {
|
|
28
|
+
const zone = findZone(drag.x, drag.y, drag.data);
|
|
29
|
+
if (drag.zone && drag.zone !== zone)
|
|
30
|
+
drag.zone.classList.remove('dnd-over');
|
|
31
|
+
drag.zone = zone;
|
|
32
|
+
drag.indicator.hidden = true;
|
|
33
|
+
if (!zone)
|
|
34
|
+
return;
|
|
35
|
+
const opts = zones.get(zone);
|
|
36
|
+
if (!opts.ordered) {
|
|
37
|
+
zone.classList.add('dnd-over');
|
|
38
|
+
drag.index = 0;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const items = zoneItems(zone, drag.source);
|
|
42
|
+
let index = items.length;
|
|
43
|
+
for (let i = 0; i < items.length; i++) {
|
|
44
|
+
const r = items[i].getBoundingClientRect();
|
|
45
|
+
if (drag.y < r.top + r.height / 2) {
|
|
46
|
+
index = i;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
drag.index = index;
|
|
51
|
+
const zr = zone.getBoundingClientRect();
|
|
52
|
+
let lineY;
|
|
53
|
+
if (items.length === 0)
|
|
54
|
+
lineY = zr.top + 4;
|
|
55
|
+
else if (index < items.length)
|
|
56
|
+
lineY = items[index].getBoundingClientRect().top - 2;
|
|
57
|
+
else
|
|
58
|
+
lineY = items[items.length - 1].getBoundingClientRect().bottom + 2;
|
|
59
|
+
Object.assign(drag.indicator.style, {
|
|
60
|
+
top: `${lineY - 2}px`,
|
|
61
|
+
left: `${zr.left}px`,
|
|
62
|
+
width: `${zr.width}px`
|
|
63
|
+
});
|
|
64
|
+
drag.indicator.hidden = false;
|
|
65
|
+
}
|
|
66
|
+
function autoScroll() {
|
|
67
|
+
if (!active)
|
|
68
|
+
return;
|
|
69
|
+
const { y } = active;
|
|
70
|
+
let dy = 0;
|
|
71
|
+
if (y < EDGE)
|
|
72
|
+
dy = -Math.ceil((EDGE - y) / 6);
|
|
73
|
+
else if (y > window.innerHeight - EDGE)
|
|
74
|
+
dy = Math.ceil((y - (window.innerHeight - EDGE)) / 6);
|
|
75
|
+
if (dy !== 0) {
|
|
76
|
+
window.scrollBy(0, dy);
|
|
77
|
+
updateTarget(active);
|
|
78
|
+
}
|
|
79
|
+
active.scrollFrame = requestAnimationFrame(autoScroll);
|
|
80
|
+
}
|
|
81
|
+
function onMove(e) {
|
|
82
|
+
if (!active)
|
|
83
|
+
return;
|
|
84
|
+
active.x = e.clientX;
|
|
85
|
+
active.y = e.clientY;
|
|
86
|
+
active.ghost.style.transform = `translate(${e.clientX - active.offsetX}px, ${e.clientY - active.offsetY}px)`;
|
|
87
|
+
updateTarget(active);
|
|
88
|
+
}
|
|
89
|
+
function suppressNextClick() {
|
|
90
|
+
const stop = (e) => {
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener('click', stop, { capture: true, once: true });
|
|
95
|
+
setTimeout(() => window.removeEventListener('click', stop, { capture: true }), 50);
|
|
96
|
+
}
|
|
97
|
+
function finish(drop) {
|
|
98
|
+
const drag = active;
|
|
99
|
+
if (!drag)
|
|
100
|
+
return;
|
|
101
|
+
active = null;
|
|
102
|
+
cancelAnimationFrame(drag.scrollFrame);
|
|
103
|
+
window.removeEventListener('pointermove', onMove);
|
|
104
|
+
window.removeEventListener('pointerup', onUp);
|
|
105
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
106
|
+
window.removeEventListener('keydown', onKey);
|
|
107
|
+
drag.ghost.remove();
|
|
108
|
+
drag.indicator.remove();
|
|
109
|
+
drag.zone?.classList.remove('dnd-over');
|
|
110
|
+
drag.source.classList.remove('dnd-source');
|
|
111
|
+
document.body.classList.remove('dnd-active');
|
|
112
|
+
suppressNextClick();
|
|
113
|
+
if (drop && drag.zone) {
|
|
114
|
+
const opts = zones.get(drag.zone);
|
|
115
|
+
opts?.ondrop(drag.data, drag.index);
|
|
116
|
+
}
|
|
117
|
+
drag.options.onend?.(drag.data);
|
|
118
|
+
// Groups collapsed for the drag expand again on the next render; then return to where we were.
|
|
119
|
+
const y = drag.startScrollY;
|
|
120
|
+
requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo({ top: y })));
|
|
121
|
+
}
|
|
122
|
+
function onUp() {
|
|
123
|
+
finish(true);
|
|
124
|
+
}
|
|
125
|
+
function onCancel() {
|
|
126
|
+
finish(false);
|
|
127
|
+
}
|
|
128
|
+
function onKey(e) {
|
|
129
|
+
if (e.key === 'Escape')
|
|
130
|
+
finish(false);
|
|
131
|
+
}
|
|
132
|
+
function begin(node, options, x, y) {
|
|
133
|
+
const rect = node.getBoundingClientRect();
|
|
134
|
+
const ghost = node.cloneNode(true);
|
|
135
|
+
ghost.classList.add('dnd-ghost');
|
|
136
|
+
Object.assign(ghost.style, {
|
|
137
|
+
position: 'fixed',
|
|
138
|
+
top: '0',
|
|
139
|
+
left: '0',
|
|
140
|
+
width: `${rect.width}px`,
|
|
141
|
+
transform: `translate(${rect.left}px, ${rect.top}px)`,
|
|
142
|
+
pointerEvents: 'none',
|
|
143
|
+
zIndex: '1000'
|
|
144
|
+
});
|
|
145
|
+
const indicator = document.createElement('div');
|
|
146
|
+
indicator.className = 'dnd-indicator';
|
|
147
|
+
indicator.hidden = true;
|
|
148
|
+
document.body.append(ghost, indicator);
|
|
149
|
+
node.classList.add('dnd-source');
|
|
150
|
+
document.body.classList.add('dnd-active');
|
|
151
|
+
navigator.vibrate?.(25);
|
|
152
|
+
active = {
|
|
153
|
+
data: options.data,
|
|
154
|
+
source: node,
|
|
155
|
+
ghost,
|
|
156
|
+
offsetX: x - rect.left,
|
|
157
|
+
offsetY: y - rect.top,
|
|
158
|
+
x,
|
|
159
|
+
y,
|
|
160
|
+
zone: null,
|
|
161
|
+
index: 0,
|
|
162
|
+
indicator,
|
|
163
|
+
scrollFrame: 0,
|
|
164
|
+
startScrollY: window.scrollY,
|
|
165
|
+
options
|
|
166
|
+
};
|
|
167
|
+
window.addEventListener('pointermove', onMove);
|
|
168
|
+
window.addEventListener('pointerup', onUp);
|
|
169
|
+
window.addEventListener('pointercancel', onCancel);
|
|
170
|
+
window.addEventListener('keydown', onKey);
|
|
171
|
+
options.onstart?.(options.data);
|
|
172
|
+
// Layout may change in onstart (e.g. groups collapse); measure after it settles.
|
|
173
|
+
requestAnimationFrame(() => active && updateTarget(active));
|
|
174
|
+
active.scrollFrame = requestAnimationFrame(autoScroll);
|
|
175
|
+
}
|
|
176
|
+
export const draggable = (node, initial) => {
|
|
177
|
+
let options = initial;
|
|
178
|
+
let stopWatching = null;
|
|
179
|
+
const cancelPress = () => {
|
|
180
|
+
stopWatching?.();
|
|
181
|
+
stopWatching = null;
|
|
182
|
+
};
|
|
183
|
+
const onPointerDown = (e) => {
|
|
184
|
+
if (options.disabled || active || e.button !== 0)
|
|
185
|
+
return;
|
|
186
|
+
cancelPress();
|
|
187
|
+
stopWatching = watchPress(e, {
|
|
188
|
+
delay: DRAG_PRESS_MS,
|
|
189
|
+
tolerance: MOVE_TOLERANCE,
|
|
190
|
+
onpress: (point) => {
|
|
191
|
+
stopWatching = null;
|
|
192
|
+
begin(node, options, point.x, point.y);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
// While dragging on touch screens, stop the page from scrolling instead of the drag.
|
|
197
|
+
const onTouchMove = (e) => {
|
|
198
|
+
if (active?.source === node && e.cancelable)
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
};
|
|
201
|
+
const onContextMenu = (e) => {
|
|
202
|
+
if (stopWatching || active)
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
};
|
|
205
|
+
node.addEventListener('pointerdown', onPointerDown);
|
|
206
|
+
node.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
207
|
+
node.addEventListener('contextmenu', onContextMenu);
|
|
208
|
+
return {
|
|
209
|
+
update(next) {
|
|
210
|
+
options = next;
|
|
211
|
+
},
|
|
212
|
+
destroy() {
|
|
213
|
+
cancelPress();
|
|
214
|
+
node.removeEventListener('pointerdown', onPointerDown);
|
|
215
|
+
node.removeEventListener('touchmove', onTouchMove);
|
|
216
|
+
node.removeEventListener('contextmenu', onContextMenu);
|
|
217
|
+
// An active drag keeps going: the ghost is a clone, and the drop handler doesn't need the node.
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
export const dropZone = (node, options) => {
|
|
222
|
+
zones.set(node, options);
|
|
223
|
+
return {
|
|
224
|
+
update(next) {
|
|
225
|
+
zones.set(node, next);
|
|
226
|
+
},
|
|
227
|
+
destroy() {
|
|
228
|
+
zones.delete(node);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
/** Move `id` to `index` within `ids` (index counted without `id`, as reported by ordered zones). */
|
|
233
|
+
export function reorder(ids, id, index) {
|
|
234
|
+
const rest = ids.filter((x) => x !== id);
|
|
235
|
+
rest.splice(Math.max(0, Math.min(index, rest.length)), 0, id);
|
|
236
|
+
return rest;
|
|
237
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { default as ActiveList } from './ActiveList.svelte';
|
|
2
|
+
export { default as AddRow } from './AddRow.svelte';
|
|
3
|
+
export { default as Checkbox } from './Checkbox.svelte';
|
|
4
|
+
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
|
|
5
|
+
export { default as Combobox, sameName, type ComboChoice, type ComboOption } from './Combobox.svelte';
|
|
6
|
+
export { default as DevWipeButton } from './DevWipeButton.svelte';
|
|
7
|
+
export { default as SearchBar } from './SearchBar.svelte';
|
|
8
|
+
export { default as Segmented } from './Segmented.svelte';
|
|
9
|
+
export { default as TextField } from './TextField.svelte';
|
|
10
|
+
export { default as Toggle } from './Toggle.svelte';
|
|
11
|
+
export { default as UndoButton } from './UndoButton.svelte';
|
|
12
|
+
export { DRAG_PRESS_MS, draggable, dropZone, isDragging, reorder, type DragData, type DraggableOptions, type DropZoneOptions } from './dnd.js';
|
|
13
|
+
export { LONG_PRESS_MS, longpress } from './longpress.js';
|
|
14
|
+
export { watchPress, type PressOptions } from './press.js';
|
|
15
|
+
export { UndoStack, type UndoEntry, type UndoStackOptions } from './undo.svelte.js';
|
|
16
|
+
export { wipeAllData, type WipeOptions } from './wipe.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { default as ActiveList } from './ActiveList.svelte';
|
|
2
|
+
export { default as AddRow } from './AddRow.svelte';
|
|
3
|
+
export { default as Checkbox } from './Checkbox.svelte';
|
|
4
|
+
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
|
|
5
|
+
export { default as Combobox, sameName } from './Combobox.svelte';
|
|
6
|
+
export { default as DevWipeButton } from './DevWipeButton.svelte';
|
|
7
|
+
export { default as SearchBar } from './SearchBar.svelte';
|
|
8
|
+
export { default as Segmented } from './Segmented.svelte';
|
|
9
|
+
export { default as TextField } from './TextField.svelte';
|
|
10
|
+
export { default as Toggle } from './Toggle.svelte';
|
|
11
|
+
export { default as UndoButton } from './UndoButton.svelte';
|
|
12
|
+
export { DRAG_PRESS_MS, draggable, dropZone, isDragging, reorder } from './dnd.js';
|
|
13
|
+
export { LONG_PRESS_MS, longpress } from './longpress.js';
|
|
14
|
+
export { watchPress } from './press.js';
|
|
15
|
+
export { UndoStack } from './undo.svelte.js';
|
|
16
|
+
export { wipeAllData } from './wipe.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
export declare const LONG_PRESS_MS = 500;
|
|
3
|
+
/**
|
|
4
|
+
* Calls `handler` after holding a pointer still for LONG_PRESS_MS (touch or mouse).
|
|
5
|
+
* The click that follows a long press is swallowed so the normal tap action doesn't also run.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <button onclick={open} use:longpress={() => startEditing(row)}>…</button>
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare const longpress: Action<HTMLElement, (() => void) | undefined>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { watchPress } from './press.js';
|
|
2
|
+
export const LONG_PRESS_MS = 500;
|
|
3
|
+
const MOVE_TOLERANCE = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Calls `handler` after holding a pointer still for LONG_PRESS_MS (touch or mouse).
|
|
6
|
+
* The click that follows a long press is swallowed so the normal tap action doesn't also run.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <button onclick={open} use:longpress={() => startEditing(row)}>…</button>
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export const longpress = (node, initial) => {
|
|
13
|
+
let handler = initial;
|
|
14
|
+
let stopWatching = null;
|
|
15
|
+
let fired = false;
|
|
16
|
+
const cancel = () => {
|
|
17
|
+
stopWatching?.();
|
|
18
|
+
stopWatching = null;
|
|
19
|
+
};
|
|
20
|
+
const onDown = (e) => {
|
|
21
|
+
if (!handler || e.button !== 0)
|
|
22
|
+
return;
|
|
23
|
+
fired = false;
|
|
24
|
+
cancel();
|
|
25
|
+
stopWatching = watchPress(e, {
|
|
26
|
+
delay: LONG_PRESS_MS,
|
|
27
|
+
tolerance: MOVE_TOLERANCE,
|
|
28
|
+
onpress: () => {
|
|
29
|
+
stopWatching = null;
|
|
30
|
+
fired = true;
|
|
31
|
+
navigator.vibrate?.(25);
|
|
32
|
+
handler?.();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
const onClick = (e) => {
|
|
37
|
+
if (fired) {
|
|
38
|
+
fired = false;
|
|
39
|
+
e.stopImmediatePropagation();
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const onContextMenu = (e) => {
|
|
44
|
+
if (handler)
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
};
|
|
47
|
+
node.addEventListener('pointerdown', onDown);
|
|
48
|
+
node.addEventListener('pointerleave', cancel);
|
|
49
|
+
node.addEventListener('click', onClick, { capture: true });
|
|
50
|
+
node.addEventListener('contextmenu', onContextMenu);
|
|
51
|
+
return {
|
|
52
|
+
update(next) {
|
|
53
|
+
handler = next;
|
|
54
|
+
},
|
|
55
|
+
destroy() {
|
|
56
|
+
cancel();
|
|
57
|
+
node.removeEventListener('pointerdown', onDown);
|
|
58
|
+
node.removeEventListener('pointerleave', cancel);
|
|
59
|
+
node.removeEventListener('click', onClick, { capture: true });
|
|
60
|
+
node.removeEventListener('contextmenu', onContextMenu);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
};
|
package/dist/press.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shared long-press detection used by the `longpress` and `draggable` actions. */
|
|
2
|
+
export interface PressOptions {
|
|
3
|
+
/** How long the pointer has to stay down, in ms. */
|
|
4
|
+
delay: number;
|
|
5
|
+
/** How far (px) the pointer may move before the press is cancelled (e.g. the page scrolls). */
|
|
6
|
+
tolerance: number;
|
|
7
|
+
/** Called once the press has been held long enough, with the latest pointer position. */
|
|
8
|
+
onpress: (point: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Watch the pointer that just went down. Moving further than `tolerance`, releasing or
|
|
15
|
+
* cancelling the pointer ends the watch; holding still for `delay` calls `onpress`.
|
|
16
|
+
* Returns a function that stops watching early.
|
|
17
|
+
*/
|
|
18
|
+
export declare function watchPress(down: PointerEvent, options: PressOptions): () => void;
|
package/dist/press.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Shared long-press detection used by the `longpress` and `draggable` actions. */
|
|
2
|
+
/**
|
|
3
|
+
* Watch the pointer that just went down. Moving further than `tolerance`, releasing or
|
|
4
|
+
* cancelling the pointer ends the watch; holding still for `delay` calls `onpress`.
|
|
5
|
+
* Returns a function that stops watching early.
|
|
6
|
+
*/
|
|
7
|
+
export function watchPress(down, options) {
|
|
8
|
+
const id = down.pointerId;
|
|
9
|
+
const start = { x: down.clientX, y: down.clientY };
|
|
10
|
+
let last = { ...start };
|
|
11
|
+
const onMove = (e) => {
|
|
12
|
+
if (e.pointerId !== id)
|
|
13
|
+
return;
|
|
14
|
+
last = { x: e.clientX, y: e.clientY };
|
|
15
|
+
if (Math.hypot(last.x - start.x, last.y - start.y) > options.tolerance)
|
|
16
|
+
stop();
|
|
17
|
+
};
|
|
18
|
+
const onEnd = (e) => {
|
|
19
|
+
if (e.pointerId === id)
|
|
20
|
+
stop();
|
|
21
|
+
};
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
stop();
|
|
24
|
+
options.onpress(last);
|
|
25
|
+
}, options.delay);
|
|
26
|
+
function stop() {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
window.removeEventListener('pointermove', onMove);
|
|
29
|
+
window.removeEventListener('pointerup', onEnd);
|
|
30
|
+
window.removeEventListener('pointercancel', onEnd);
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('pointermove', onMove);
|
|
33
|
+
window.addEventListener('pointerup', onEnd);
|
|
34
|
+
window.addEventListener('pointercancel', onEnd);
|
|
35
|
+
return stop;
|
|
36
|
+
}
|