Skip to content

Commit 41b18b2

Browse files
authored
Improve collection filing and sidebar moves (#212)
* feat: improve collection filing workflows * fix: expand sidebar group drop targets * fix: use explicit collection drag handles * fix: use pointer-driven collection moves * feat: show collection drag preview * fix: prevent stale collection move undo * fix: make collection moves keyboard accessible * fix: serialize collection move persistence * fix: avoid sidebar shift during drag * fix: restore focus after closing move menu
1 parent 9e0eb07 commit 41b18b2

3 files changed

Lines changed: 470 additions & 47 deletions

File tree

packages/web/src/components/CollectionPicker.svelte

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import type { Collection } from '@inbox-rs/rs-module';
2+
import type { Collection, CollectionGroup } from '@inbox-rs/rs-module';
33
import { get } from 'svelte/store';
44
import {
55
appConfig,
@@ -9,6 +9,7 @@
99
items,
1010
orphanCollections,
1111
sortedGroups,
12+
storeGroup,
1213
updateConfig,
1314
} from '../lib/stores';
1415
import {
@@ -43,6 +44,8 @@
4344
let createName = $state('');
4445
let creating = $state(false);
4546
let createError = $state('');
47+
let creatingNewGroup = $state(false);
48+
let createGroupName = $state('');
4649
4750
// Autofocusing the search field pops the keyboard over half the sheet on
4851
// touch devices — desktop-only.
@@ -128,28 +131,43 @@
128131
function openCreate() {
129132
createName = query.trim();
130133
createGroupId = pickInitialGroupId();
134+
creatingNewGroup = get(sortedGroups).length === 0;
135+
createGroupName = '';
131136
createError = '';
132137
showCreate = true;
133138
}
134139
135140
async function handleCreate() {
136141
const name = createName.trim();
137-
if (!name || !createGroupId || creating) return;
142+
const groupName = createGroupName.trim();
143+
if (!name || creating || (creatingNewGroup ? !groupName : !createGroupId)) return;
138144
creating = true;
139145
createError = '';
140146
try {
147+
let destinationGroupId = createGroupId;
148+
if (creatingNewGroup) {
149+
const group: CollectionGroup = {
150+
id: crypto.randomUUID(),
151+
name: groupName,
152+
collectionIds: [],
153+
createdAt: new Date().toISOString(),
154+
color: randomPresetColor(),
155+
};
156+
await storeGroup(group);
157+
destinationGroupId = group.id;
158+
}
141159
const col: Collection = {
142160
id: crypto.randomUUID(),
143161
name,
144162
itemIds: [],
145163
createdAt: new Date().toISOString(),
146164
color: randomPresetColor(),
147-
groupId: createGroupId,
165+
groupId: destinationGroupId,
148166
};
149167
await createCollection(col);
150168
// Remember the group like CollectionFormModal does — best-effort.
151169
try {
152-
await updateConfig({ lastSelectedGroupId: createGroupId });
170+
await updateConfig({ lastSelectedGroupId: destinationGroupId });
153171
} catch (e) {
154172
console.error('Failed to persist lastSelectedGroupId', e);
155173
}
@@ -231,17 +249,45 @@
231249
}}
232250
/>
233251
{#if $sortedGroups.length > 0}
234-
<select
235-
class="create-select"
236-
aria-label="Group"
237-
bind:value={createGroupId}
238-
>
239-
{#each $sortedGroups as g (g.id)}
240-
<option value={g.id}>{g.name}</option>
241-
{/each}
242-
</select>
252+
{#if creatingNewGroup}
253+
<input
254+
type="text"
255+
class="create-input"
256+
bind:value={createGroupName}
257+
placeholder="Group name"
258+
aria-label="New group name"
259+
onkeydown={(e) => {
260+
if (e.key === 'Enter') handleCreate();
261+
}}
262+
/>
263+
<button type="button" class="group-switch" onclick={() => (creatingNewGroup = false)}>
264+
Choose an existing group
265+
</button>
266+
{:else}
267+
<select
268+
class="create-select"
269+
aria-label="Group"
270+
bind:value={createGroupId}
271+
>
272+
{#each $sortedGroups as g (g.id)}
273+
<option value={g.id}>{g.name}</option>
274+
{/each}
275+
</select>
276+
<button type="button" class="group-switch" onclick={() => (creatingNewGroup = true)}>
277+
Create a new group
278+
</button>
279+
{/if}
243280
{:else}
244-
<p class="empty">No groups yet — create one in the filter bar first.</p>
281+
<input
282+
type="text"
283+
class="create-input"
284+
bind:value={createGroupName}
285+
placeholder="Group name"
286+
aria-label="New group name"
287+
onkeydown={(e) => {
288+
if (e.key === 'Enter') handleCreate();
289+
}}
290+
/>
245291
{/if}
246292
{#if createError}
247293
<p class="error" role="status" aria-live="polite">{createError}</p>
@@ -255,7 +301,7 @@
255301
<button
256302
type="button"
257303
class="btn-create"
258-
disabled={!createName.trim() || !createGroupId || creating}
304+
disabled={!createName.trim() || (creatingNewGroup ? !createGroupName.trim() : !createGroupId) || creating}
259305
onclick={handleCreate}
260306
>
261307
{creating ? 'Creating…' : 'Create & file'}
@@ -690,6 +736,17 @@
690736
margin-top: 0.2rem;
691737
}
692738
739+
.group-switch {
740+
align-self: flex-start;
741+
padding: 0;
742+
border: none;
743+
background: none;
744+
color: var(--accent);
745+
font: inherit;
746+
font-size: 0.82rem;
747+
cursor: pointer;
748+
}
749+
693750
.btn-cancel {
694751
background: none;
695752
border: 1px solid var(--border);

packages/web/src/components/CollectionPicker.svelte.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ vi.mock('../lib/stores', async () => {
1414
items: writable({}),
1515
orphanCollections: writable([]),
1616
sortedGroups: writable([]),
17+
storeGroup: vi.fn().mockResolvedValue(undefined),
1718
updateConfig: vi.fn().mockResolvedValue(undefined),
1819
};
1920
});
2021

21-
import { groupCollections, groups, sortedGroups } from '../lib/stores';
22+
import {
23+
createCollection,
24+
groupCollections,
25+
groups,
26+
sortedGroups,
27+
storeGroup,
28+
} from '../lib/stores';
2229
import CollectionPicker from './CollectionPicker.svelte';
2330

2431
const writableStore = <T>(store: unknown) => store as Writable<T>;
@@ -28,6 +35,10 @@ describe('CollectionPicker', () => {
2835
let component: ReturnType<typeof mount> | undefined;
2936

3037
beforeEach(() => {
38+
vi.clearAllMocks();
39+
writableStore<Record<string, CollectionGroup>>(groups).set({});
40+
writableStore<CollectionGroup[]>(sortedGroups).set([]);
41+
writableStore<Record<string, Collection[]>>(groupCollections).set({});
3142
localStorage.clear();
3243
host = document.createElement('div');
3344
document.body.appendChild(host);
@@ -100,4 +111,33 @@ describe('CollectionPicker', () => {
100111
suggestedRows[1]?.querySelector('.suggestion-group')?.textContent.trim(),
101112
).toBe('Personal');
102113
});
114+
115+
it('creates a group and collection together when filing without groups', async () => {
116+
const onpick = vi.fn();
117+
component = mount(CollectionPicker, {
118+
target: host,
119+
props: { item: { title: 'First item' }, onpick, onclose: vi.fn() },
120+
});
121+
flushSync();
122+
123+
host.querySelector<HTMLButtonElement>('.create-option')?.click();
124+
flushSync();
125+
const inputs = host.querySelectorAll<HTMLInputElement>('.create-input');
126+
inputs[0]!.value = 'Reading';
127+
inputs[0]!.dispatchEvent(new Event('input', { bubbles: true }));
128+
inputs[1]!.value = 'Personal';
129+
inputs[1]!.dispatchEvent(new Event('input', { bubbles: true }));
130+
flushSync();
131+
host.querySelector<HTMLButtonElement>('.btn-create')?.click();
132+
await vi.waitFor(() => expect(onpick).toHaveBeenCalledOnce());
133+
134+
expect(storeGroup).toHaveBeenCalledWith(
135+
expect.objectContaining({ name: 'Personal' }),
136+
);
137+
const createdGroup = vi.mocked(storeGroup).mock.calls[0]![0];
138+
expect(createCollection).toHaveBeenCalledWith(
139+
expect.objectContaining({ name: 'Reading', groupId: createdGroup.id }),
140+
);
141+
expect(onpick).toHaveBeenCalledWith(expect.any(String));
142+
});
103143
});

0 commit comments

Comments
 (0)