@wells-nelson/interactions 0.0.0-stage → 1.0.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.
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
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # @wells-nelson/interactions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Svelte 5 inputs, actions and account UI. Peer dependencies: `@wells-nelson/displays` (install it
4
+ alongside, so the app and this package share one theme and icon registry) and `svelte`. No
5
+ storage dependency: the account components take the sync layer through interfaces.
6
+
7
+ ```sh
8
+ npm install @wells-nelson/displays @wells-nelson/interactions
9
+ ```
10
+
11
+ ## Inputs
12
+
13
+ `TextField` (label, error, hint), `Checkbox` (optionally hidden label), `Toggle` (a switch for
14
+ settings that apply at once), `Combobox` (type to filter, offers "Create …"; plus `sameName`),
15
+ `AddRow` (inline "name + plus" that refocuses), `Segmented` (one of a few) and `SearchBar`
16
+ (fixed at the bottom, with a clear button).
17
+
18
+ ```svelte
19
+ <ActiveList
20
+ items={people}
21
+ noun="People"
22
+ label={(p) => p.name}
23
+ onopen={edit}
24
+ ontoggle={(p, on) => setActive(p, on)}
25
+ bind:inactiveOpen={() => !prefs.isCollapsed('people-inactive', true), (open) => prefs.setCollapsed('people-inactive', !open)}
26
+ />
27
+
28
+ <CollapsibleSection title={status.name} count={rows.length} collapsed={…} ontogglecollapse={…}
29
+ onadd={…} onedit={…} dragData={{ type: 'section', id: status.id }} drop={{ accepts, ondrop, ordered: true }}>
30
+ {#each rows as row (row.id)}<li data-dnd-item>…</li>{/each}
31
+ </CollapsibleSection>
32
+ ```
33
+
34
+ Both take `appearance`: `card` (default) draws rounded cards; `lines` fills the container edge to
35
+ edge with rules between rows, like lines on paper. A `lines` CollapsibleSection's pinned header
36
+ needs an opaque background: set `--section-header-bg` to whatever it sits on (default `--bg`).
37
+
38
+ ## Actions
39
+
40
+ - `use:longpress={handler}`: fires after a 500 ms still hold and swallows the click that follows.
41
+ - `use:draggable={{ data, onstart, onend }}` / `use:dropZone={{ accepts, ondrop, ordered }}` /
42
+ `reorder(ids, id, index)`: long-press (300 ms) drag for touch and mouse, with an insertion line
43
+ for ordered zones and the scroll position restored after the drop. Both actions use the same
44
+ press detection (`watchPress`).
45
+
46
+ ## Undo
47
+
48
+ ```ts
49
+ const undo = new UndoStack(); // button visible 5 s, history kept 1 min idle
50
+ setDone(task, true);
51
+ undo.push({ undo: () => setDone(task, false) });
52
+ ```
53
+
54
+ ```svelte
55
+ <UndoButton stack={undo} />
56
+ ```
57
+
58
+ ## Dev wipe
59
+
60
+ ```svelte
61
+ {#if import.meta.env.DEV}
62
+ <DevWipeButton onconfirm={() => wipeAllData({ beforeWipe: store.resetDevice, redirectTo: resolve('/') })} />
63
+ {/if}
64
+ ```
65
+
66
+ ## Account and sharing (`@wells-nelson/interactions/account`)
67
+
68
+ `AccountPanel` (or the `SignInCard`, `SyncStatusCard`, `SignOutCard` it's made of),
69
+ `LocalItemsPrompt`, `ShareModal`, `ContainerEditModal`. Pass `store.cloud` as `cloud`/`account`
70
+ and `store.supabase` as `members` from `@wells-nelson/data-storage`, or anything with the same
71
+ shape. Wording comes from `nouns` (`{ singular: 'project', plural: 'projects' }`) and `appName`.
72
+
73
+ ```svelte
74
+ <AccountPanel cloud={store.cloud} account={store.cloud} nouns={NOUNS} appName="My App"
75
+ returnUrl={() => new URL(resolve('/profile'), location.origin).href}
76
+ pendingName={(id) => projects.find(id)?.container.name ?? 'Deleted project'} />
77
+
78
+ <ShareModal containerId={id} name={entry.container.name} location={entry?.location} ready={projects.ready}
79
+ role={entry.role} roles cloud={store.cloud} members={store.supabase} nouns={NOUNS} appName="My App"
80
+ signInHref={resolve('/profile')} onmakecloud={() => store.ops.makeCloud(id)} {onclose} />
81
+ ```
82
+
83
+ With `roles`, `ShareModal` shows a role picker and protects the last owner, and
84
+ `ContainerEditModal` offers "Leave" instead of "Delete" while other owners remain. Without roles
85
+ every member counts as an owner. The sandbox's Account page drives every state with a mock.
@@ -0,0 +1,168 @@
1
+ <script lang="ts" generics="T extends { id: string; active: boolean }">
2
+ import { Icon, IconButton } from '@wells-nelson/displays';
3
+ import Checkbox from './Checkbox.svelte';
4
+
5
+ /**
6
+ * Active rows first, then a collapsible inactive section. Each row has an active checkbox and a
7
+ * pencil that opens it; tapping the name does nothing. Bind `inactiveOpen` to persist whether
8
+ * the inactive section is expanded (e.g. to device preferences).
9
+ */
10
+ let {
11
+ items,
12
+ noun,
13
+ label,
14
+ onopen,
15
+ ontoggle,
16
+ readOnly = false,
17
+ inactiveOpen = $bindable(false),
18
+ emptyText = 'None yet.',
19
+ appearance = 'card'
20
+ }: {
21
+ items: T[];
22
+ /** Plural noun for headings, e.g. "Members". */
23
+ noun: string;
24
+ label: (item: T) => string;
25
+ onopen: (item: T) => void;
26
+ ontoggle: (item: T, active: boolean) => void;
27
+ readOnly?: boolean;
28
+ inactiveOpen?: boolean;
29
+ emptyText?: string;
30
+ /**
31
+ * `card` (default): rounded cards. `lines`: fills its container edge to edge with no card,
32
+ * so the rows read like lines on paper.
33
+ */
34
+ appearance?: 'card' | 'lines';
35
+ } = $props();
36
+
37
+ const sectionClass = $derived(['group', appearance, appearance === 'card' && 'card']);
38
+
39
+ const active = $derived(items.filter((i) => i.active));
40
+ const inactive = $derived(items.filter((i) => !i.active));
41
+ </script>
42
+
43
+ {#snippet row(item: T)}
44
+ <li class="row">
45
+ {#if readOnly}
46
+ <span class="state" class:on={item.active}><Icon name={item.active ? 'check' : 'x'} size={16} /></span>
47
+ {:else}
48
+ <Checkbox
49
+ checked={item.active}
50
+ label={`${label(item)} active`}
51
+ hideLabel
52
+ onchange={(checked) => ontoggle(item, checked)}
53
+ />
54
+ {/if}
55
+ <span class="name">{label(item)}</span>
56
+ <IconButton icon="pencil" label={`${readOnly ? 'View' : 'Edit'} ${label(item)}`} size={18} onclick={() => onopen(item)} />
57
+ </li>
58
+ {/snippet}
59
+
60
+ <section class={sectionClass}>
61
+ <h2>Active {noun.toLowerCase()} <span class="count">{active.length}</span></h2>
62
+ <ul>
63
+ {#each active as item (item.id)}
64
+ {@render row(item)}
65
+ {:else}
66
+ <li class="muted empty-row">{emptyText}</li>
67
+ {/each}
68
+ </ul>
69
+ </section>
70
+
71
+ {#if inactive.length > 0}
72
+ <section class={sectionClass}>
73
+ <button type="button" class="toggle" aria-expanded={inactiveOpen} onclick={() => (inactiveOpen = !inactiveOpen)}>
74
+ <span class="chev" class:collapsed={!inactiveOpen}><Icon name="chevron" size={20} /></span>
75
+ Inactive {noun.toLowerCase()} <span class="count">{inactive.length}</span>
76
+ </button>
77
+ {#if inactiveOpen}
78
+ <ul>
79
+ {#each inactive as item (item.id)}
80
+ {@render row(item)}
81
+ {/each}
82
+ </ul>
83
+ {/if}
84
+ </section>
85
+ {/if}
86
+
87
+ <style>
88
+ .card {
89
+ margin-bottom: 0.75rem;
90
+ overflow: hidden;
91
+ }
92
+ .lines {
93
+ width: 100%;
94
+ border-bottom: 1px solid var(--border);
95
+ }
96
+ h2,
97
+ .toggle {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 0.5rem;
101
+ margin: 0;
102
+ padding: 0.75rem 1rem;
103
+ font-size: 1rem;
104
+ font-weight: 700;
105
+ }
106
+ .toggle {
107
+ width: 100%;
108
+ border: none;
109
+ background: none;
110
+ cursor: pointer;
111
+ padding-left: 0.5rem;
112
+ color: var(--muted);
113
+ }
114
+ .chev {
115
+ display: grid;
116
+ transition: transform 0.15s;
117
+ }
118
+ .chev.collapsed {
119
+ transform: rotate(-90deg);
120
+ }
121
+ .count {
122
+ min-width: 1.6rem;
123
+ padding: 0 0.45rem;
124
+ border-radius: 1rem;
125
+ background: var(--surface-2);
126
+ color: var(--muted);
127
+ font-size: 0.8rem;
128
+ text-align: center;
129
+ line-height: 1.5rem;
130
+ font-weight: 600;
131
+ }
132
+ ul {
133
+ list-style: none;
134
+ margin: 0;
135
+ padding: 0;
136
+ }
137
+ .row {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 0.25rem;
141
+ min-height: 3rem;
142
+ padding: 0 0.5rem 0 0.25rem;
143
+ border-top: 1px solid var(--border);
144
+ }
145
+ .state {
146
+ display: grid;
147
+ place-items: center;
148
+ width: 2.5rem;
149
+ color: var(--muted);
150
+ }
151
+ .state.on {
152
+ color: var(--accent-text);
153
+ }
154
+ .name {
155
+ flex: 1;
156
+ min-width: 0;
157
+ padding: 0.7rem 0.25rem;
158
+ font-weight: 500;
159
+ overflow-wrap: anywhere;
160
+ }
161
+ .row :global(.icon-btn) {
162
+ color: var(--muted);
163
+ }
164
+ .empty-row {
165
+ padding: 0.75rem 1rem;
166
+ border-top: 1px solid var(--border);
167
+ }
168
+ </style>
@@ -0,0 +1,54 @@
1
+ declare function $$render<T extends {
2
+ id: string;
3
+ active: boolean;
4
+ }>(): {
5
+ props: {
6
+ items: T[];
7
+ /** Plural noun for headings, e.g. "Members". */
8
+ noun: string;
9
+ label: (item: T) => string;
10
+ onopen: (item: T) => void;
11
+ ontoggle: (item: T, active: boolean) => void;
12
+ readOnly?: boolean;
13
+ inactiveOpen?: boolean;
14
+ emptyText?: string;
15
+ /**
16
+ * `card` (default): rounded cards. `lines`: fills its container edge to edge with no card,
17
+ * so the rows read like lines on paper.
18
+ */
19
+ appearance?: "card" | "lines";
20
+ };
21
+ exports: {};
22
+ bindings: "inactiveOpen";
23
+ slots: {};
24
+ events: {};
25
+ };
26
+ declare class __sveltets_Render<T extends {
27
+ id: string;
28
+ active: boolean;
29
+ }> {
30
+ props(): ReturnType<typeof $$render<T>>['props'];
31
+ events(): ReturnType<typeof $$render<T>>['events'];
32
+ slots(): ReturnType<typeof $$render<T>>['slots'];
33
+ bindings(): "inactiveOpen";
34
+ exports(): {};
35
+ }
36
+ interface $$IsomorphicComponent {
37
+ new <T extends {
38
+ id: string;
39
+ active: boolean;
40
+ }>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<T>['exports']>;
43
+ <T extends {
44
+ id: string;
45
+ active: boolean;
46
+ }>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
47
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
48
+ }
49
+ declare const ActiveList: $$IsomorphicComponent;
50
+ type ActiveList<T extends {
51
+ id: string;
52
+ active: boolean;
53
+ }> = InstanceType<typeof ActiveList<T>>;
54
+ export default ActiveList;
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@wells-nelson/displays';
3
+
4
+ let { placeholder, label, onadd }: { placeholder: string; label: string; onadd: (name: string) => void } =
5
+ $props();
6
+
7
+ let value = $state('');
8
+ let input: HTMLInputElement | undefined = $state();
9
+
10
+ function submit(e: SubmitEvent) {
11
+ e.preventDefault();
12
+ const name = value.trim();
13
+ if (!name) return;
14
+ onadd(name);
15
+ value = '';
16
+ // Ready for the next one, whether Enter or the plus button was used.
17
+ input?.focus();
18
+ }
19
+ </script>
20
+
21
+ <form class="add-row" onsubmit={submit}>
22
+ <input type="text" bind:this={input} bind:value {placeholder} aria-label={label} enterkeyhint="done" />
23
+ <button type="submit" aria-label={label} title={label} disabled={!value.trim()}>
24
+ <Icon name="plus" />
25
+ </button>
26
+ </form>
27
+
28
+ <style>
29
+ .add-row {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 0.5rem;
33
+ padding: 0.4rem 0.4rem 0.4rem 0.9rem;
34
+ background: var(--surface);
35
+ border: 1px dashed var(--border-strong);
36
+ border-radius: var(--radius);
37
+ }
38
+ input {
39
+ flex: 1;
40
+ min-width: 0;
41
+ border: none;
42
+ background: transparent;
43
+ padding: 0.4rem 0;
44
+ box-shadow: none;
45
+ }
46
+ input:focus {
47
+ outline: none;
48
+ box-shadow: none;
49
+ }
50
+ .add-row:focus-within {
51
+ border-style: solid;
52
+ border-color: var(--accent);
53
+ }
54
+ button {
55
+ display: grid;
56
+ place-items: center;
57
+ width: 2.4rem;
58
+ height: 2.4rem;
59
+ border: none;
60
+ border-radius: 50%;
61
+ background: var(--accent);
62
+ color: var(--on-accent);
63
+ cursor: pointer;
64
+ }
65
+ button:disabled {
66
+ background: var(--border-strong);
67
+ cursor: not-allowed;
68
+ }
69
+ </style>
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ placeholder: string;
3
+ label: string;
4
+ onadd: (name: string) => void;
5
+ };
6
+ declare const AddRow: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type AddRow = ReturnType<typeof AddRow>;
8
+ export default AddRow;
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@wells-nelson/displays';
3
+
4
+ let {
5
+ checked = $bindable(false),
6
+ label,
7
+ hideLabel = false,
8
+ onchange
9
+ }: { checked?: boolean; label: string; hideLabel?: boolean; onchange?: (checked: boolean) => void } =
10
+ $props();
11
+ </script>
12
+
13
+ <label class="checkbox" class:bare={hideLabel}>
14
+ <input type="checkbox" bind:checked onchange={() => onchange?.(checked)} aria-label={hideLabel ? label : undefined} />
15
+ <span class="box" aria-hidden="true"><Icon name="check" size={16} /></span>
16
+ {#if !hideLabel}<span class="text">{label}</span>{/if}
17
+ </label>
18
+
19
+ <style>
20
+ .checkbox {
21
+ position: relative;
22
+ display: inline-flex;
23
+ align-items: center;
24
+ gap: 0.6rem;
25
+ cursor: pointer;
26
+ min-height: 2.25rem;
27
+ }
28
+ .bare {
29
+ min-width: 2.5rem;
30
+ justify-content: center;
31
+ }
32
+ /* The real input covers the whole control (invisible), so every tap lands on it. */
33
+ input {
34
+ position: absolute;
35
+ inset: 0;
36
+ width: 100%;
37
+ height: 100%;
38
+ margin: 0;
39
+ opacity: 0;
40
+ cursor: pointer;
41
+ z-index: 1;
42
+ }
43
+ .box {
44
+ display: grid;
45
+ place-items: center;
46
+ width: 1.4rem;
47
+ height: 1.4rem;
48
+ border: 2px solid var(--border-strong);
49
+ border-radius: 0.4rem;
50
+ color: transparent;
51
+ background: var(--surface);
52
+ transition:
53
+ background-color 0.15s,
54
+ border-color 0.15s;
55
+ }
56
+ input:checked + .box {
57
+ background: var(--accent);
58
+ border-color: var(--accent);
59
+ color: var(--on-accent);
60
+ }
61
+ input:focus-visible + .box {
62
+ outline: 2px solid var(--accent);
63
+ outline-offset: 2px;
64
+ }
65
+ </style>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ checked?: boolean;
3
+ label: string;
4
+ hideLabel?: boolean;
5
+ onchange?: (checked: boolean) => void;
6
+ };
7
+ declare const Checkbox: import("svelte").Component<$$ComponentProps, {}, "checked">;
8
+ type Checkbox = ReturnType<typeof Checkbox>;
9
+ export default Checkbox;
@@ -0,0 +1,225 @@
1
+ <script lang="ts">
2
+ import { Icon, IconButton } from '@wells-nelson/displays';
3
+ import { tick, type Snippet } from 'svelte';
4
+ import { draggable, dropZone, type DragData, type DropZoneOptions } from './dnd.js';
5
+
6
+ /**
7
+ * A card whose header (chevron, title, count, add and edit actions) stays pinned below the app
8
+ * header while you scroll through its rows. Collapsed sections hide rows with CSS rather than
9
+ * unmounting them, so a row being dragged stays attached to the page.
10
+ * Children are rendered inside a <ul>; give draggable rows `data-dnd-item`.
11
+ */
12
+ let {
13
+ title,
14
+ count,
15
+ countLabel = (n: number) => `${n} items`,
16
+ collapsed,
17
+ ontogglecollapse,
18
+ onedit,
19
+ editLabel = 'Edit',
20
+ onadd,
21
+ addLabel = 'Add item',
22
+ dragData,
23
+ ondragstart,
24
+ ondragend,
25
+ drop,
26
+ variant = 'default',
27
+ appearance = 'card',
28
+ appHeaderSelector = '.app-header',
29
+ children
30
+ }: {
31
+ title: string;
32
+ count: number;
33
+ countLabel?: (count: number) => string;
34
+ collapsed: boolean;
35
+ ontogglecollapse: () => void;
36
+ /** Opens the section's settings (pencil icon, right of the plus). */
37
+ onedit?: () => void;
38
+ editLabel?: string;
39
+ onadd?: () => void;
40
+ addLabel?: string;
41
+ /** Set to make the whole section long-press draggable (reordering sections). */
42
+ dragData?: DragData;
43
+ ondragstart?: () => void;
44
+ ondragend?: () => void;
45
+ /** Accept dropped rows. */
46
+ drop?: DropZoneOptions;
47
+ /** `subtle` gives the header a muted background (e.g. an "unallocated" bucket). */
48
+ variant?: 'default' | 'subtle';
49
+ /**
50
+ * `card` (default): a rounded card. `lines`: fills its container edge to edge with no card,
51
+ * so sections and rows read like lines on paper. The pinned header needs an opaque
52
+ * background: set `--section-header-bg` to whatever it sits on (default `--bg`).
53
+ */
54
+ appearance?: 'card' | 'lines';
55
+ /** The fixed app header, measured when scrolling a collapsed section back into view. */
56
+ appHeaderSelector?: string;
57
+ children: Snippet;
58
+ } = $props();
59
+
60
+ const noDrop: DropZoneOptions = { accepts: () => false, ondrop: () => {} };
61
+ const noDrag: DragData = { type: 'none', id: '' };
62
+
63
+ let section: HTMLElement | undefined = $state();
64
+ let header: HTMLElement | undefined = $state();
65
+
66
+ /** Space left above a section scrolled back into view after collapsing. */
67
+ const COLLAPSE_MARGIN = 12;
68
+
69
+ /**
70
+ * Collapsing from the pinned header while scrolled down inside a long section would leave the
71
+ * page somewhere further down; scroll up so the collapsed section is in view near the top.
72
+ * Scrolling up brings an auto-hiding app header back, so the section lands below the header's
73
+ * full height plus a small margin, not where the pinned header happened to be.
74
+ */
75
+ async function toggle() {
76
+ const pinnedTop = header?.getBoundingClientRect().top ?? 0;
77
+ const wasPinned = !collapsed && !!section && section.getBoundingClientRect().top < pinnedTop - 1;
78
+ ontogglecollapse();
79
+ if (!wasPinned) return;
80
+ await tick();
81
+ if (!section) return;
82
+ const appHeader = document.querySelector(appHeaderSelector);
83
+ const target = (appHeader?.getBoundingClientRect().height ?? 0) + COLLAPSE_MARGIN;
84
+ window.scrollBy({ top: section.getBoundingClientRect().top - target });
85
+ }
86
+ </script>
87
+
88
+ <section
89
+ bind:this={section}
90
+ class={['group', variant, appearance, appearance === 'card' && 'card', collapsed && 'collapsed']}
91
+ data-dnd-item={dragData ? '' : undefined}
92
+ use:dropZone={drop ?? noDrop}
93
+ >
94
+ <div
95
+ bind:this={header}
96
+ class="group-header"
97
+ use:draggable={{ data: dragData ?? noDrag, disabled: !dragData, onstart: ondragstart, onend: ondragend }}
98
+ >
99
+ <button
100
+ type="button"
101
+ class="collapse"
102
+ aria-expanded={!collapsed}
103
+ aria-label={collapsed ? `Expand ${title}` : `Collapse ${title}`}
104
+ onclick={toggle}
105
+ >
106
+ <span class="chev" class:collapsed><Icon name="chevron" size={20} /></span>
107
+ </button>
108
+ <!-- The name toggles collapse too; settings live behind the pencil. -->
109
+ <button type="button" class="title" aria-expanded={!collapsed} onclick={toggle}>{title}</button>
110
+ <span class="count" aria-label={countLabel(count)}>{count}</span>
111
+ {#if onadd}
112
+ <IconButton icon="plus" label={addLabel} onclick={onadd} />
113
+ {/if}
114
+ {#if onedit}
115
+ <IconButton icon="pencil" label={editLabel} size={20} onclick={onedit} />
116
+ {/if}
117
+ </div>
118
+ <ul class="items" hidden={collapsed}>
119
+ {@render children()}
120
+ </ul>
121
+ </section>
122
+
123
+ <style>
124
+ .group {
125
+ position: relative;
126
+ }
127
+ .group-header {
128
+ position: sticky;
129
+ top: var(--header-offset, 0px);
130
+ z-index: 5;
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 0.15rem;
134
+ min-height: 3rem;
135
+ padding: 0.15rem 0.25rem;
136
+ background: var(--surface);
137
+ transition: top 0.2s ease;
138
+ -webkit-touch-callout: none;
139
+ user-select: none;
140
+ }
141
+ .subtle .group-header {
142
+ background: var(--surface-2);
143
+ }
144
+
145
+ /*
146
+ * Card: the section can't clip its children (that would break the sticky header), so whatever
147
+ * sits at the bottom rounds its own corners to match the card's: the header while nothing is
148
+ * below it, otherwise the rows. Inner radius = card radius minus its 1px border.
149
+ */
150
+ .card {
151
+ --inner-radius: calc(var(--radius) - 1px);
152
+ }
153
+ .card .group-header {
154
+ border-radius: var(--inner-radius) var(--inner-radius) 0 0;
155
+ }
156
+ .card.collapsed .group-header,
157
+ .card .group-header:has(+ .items:empty) {
158
+ border-radius: var(--inner-radius);
159
+ }
160
+ .card .items {
161
+ overflow: hidden;
162
+ border-radius: 0 0 var(--inner-radius) var(--inner-radius);
163
+ }
164
+
165
+ /* Lines: edge to edge, a rule under each section; rows bring their own rules. */
166
+ .lines {
167
+ width: 100%;
168
+ border-bottom: 1px solid var(--border);
169
+ }
170
+ .lines .group-header {
171
+ background: var(--section-header-bg, var(--bg));
172
+ }
173
+ .lines.subtle .group-header {
174
+ background: var(--surface-2);
175
+ }
176
+ .collapse {
177
+ display: grid;
178
+ place-items: center;
179
+ width: 2.5rem;
180
+ height: 2.5rem;
181
+ border: none;
182
+ background: none;
183
+ border-radius: 50%;
184
+ cursor: pointer;
185
+ color: var(--muted);
186
+ }
187
+ .collapse:hover {
188
+ background: var(--surface-hover);
189
+ }
190
+ .chev {
191
+ transition: transform 0.15s;
192
+ }
193
+ .chev.collapsed {
194
+ transform: rotate(-90deg);
195
+ }
196
+ .title {
197
+ flex: 1;
198
+ min-width: 0;
199
+ padding: 0.5rem 0.25rem;
200
+ border: none;
201
+ background: none;
202
+ text-align: left;
203
+ font-weight: 700;
204
+ font-size: 1.02rem;
205
+ cursor: pointer;
206
+ overflow-wrap: anywhere;
207
+ }
208
+ .count {
209
+ min-width: 1.6rem;
210
+ padding: 0 0.45rem;
211
+ border-radius: 1rem;
212
+ background: var(--surface-2);
213
+ color: var(--muted);
214
+ font-size: 0.8rem;
215
+ text-align: center;
216
+ line-height: 1.5rem;
217
+ }
218
+ .items {
219
+ margin: 0;
220
+ padding: 0;
221
+ }
222
+ .items:empty {
223
+ display: none;
224
+ }
225
+ </style>