@vit-foundation/ui 0.15.0 → 0.17.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 (45) hide show
  1. package/README.md +10 -10
  2. package/dist/components/jobs/JobList.svelte +11 -19
  3. package/dist/components/jobs/JobList.svelte.d.ts +1 -1
  4. package/dist/components/layout/Footer.svelte +9 -15
  5. package/dist/components/layout/Nav.svelte +10 -15
  6. package/dist/components/projects/ProjectCard.svelte +2 -2
  7. package/dist/components/projects/ProjectCard.svelte.d.ts +1 -1
  8. package/dist/components/team/CollaboratorList.svelte +10 -16
  9. package/dist/components/timeline/Timeline.svelte +10 -23
  10. package/dist/components/timeline/TimelineMilestone.svelte +2 -2
  11. package/dist/components/timeline/TimelineMilestone.svelte.d.ts +1 -1
  12. package/dist/components/weeklies/WeeklieCard.svelte +2 -2
  13. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +1 -1
  14. package/dist/config/index.d.ts +1 -1
  15. package/dist/config/messages.js +5 -1
  16. package/dist/config/types.d.ts +35 -3
  17. package/dist/content-components.d.ts +6 -1
  18. package/dist/content-components.js +4 -1
  19. package/dist/edit/Editable.svelte +22 -38
  20. package/dist/edit/chrome/EditPanel.svelte +2 -2
  21. package/dist/edit/chrome/EditPanel.svelte.d.ts +2 -2
  22. package/dist/edit/chrome/LinkEdit.svelte +36 -4
  23. package/dist/edit/chrome/LinkEdit.svelte.d.ts +5 -1
  24. package/dist/edit/chrome/PropertyRow.svelte +69 -55
  25. package/dist/edit/chrome/PropertyRow.svelte.d.ts +11 -5
  26. package/dist/edit/collection.svelte.d.ts +49 -0
  27. package/dist/edit/collection.svelte.js +39 -0
  28. package/dist/edit/commit.svelte.d.ts +58 -0
  29. package/dist/edit/commit.svelte.js +70 -0
  30. package/dist/edit/helpers.d.ts +5 -5
  31. package/dist/edit/helpers.js +2 -2
  32. package/dist/edit/index.d.ts +4 -1
  33. package/dist/edit/index.js +1 -0
  34. package/dist/edit/types.d.ts +24 -9
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/utils/milestones.d.ts +15 -0
  38. package/dist/utils/milestones.js +17 -0
  39. package/dist/utils/paths.d.ts +6 -0
  40. package/dist/utils/paths.js +14 -0
  41. package/dist/utils/url-filters.svelte.d.ts +58 -0
  42. package/dist/utils/url-filters.svelte.js +47 -0
  43. package/dist/utils/weekly-list.svelte.d.ts +82 -0
  44. package/dist/utils/weekly-list.svelte.js +120 -0
  45. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { getUiConfig } from '../config/context.js';
4
4
  import { getEditAdapter } from './context.js';
5
+ import { commitState } from './commit.svelte.js';
5
6
  import type { EditDescriptor } from './types.js';
6
7
 
7
8
  /**
@@ -58,30 +59,22 @@
58
59
  const adapter = getEditAdapter();
59
60
  const config = getUiConfig();
60
61
 
61
- let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
62
- /** Last persisted value: what Escape restores and blur diffs against. */
62
+ /**
63
+ * The commit lifecycle — the four states, the announcements, the last
64
+ * persisted value and the follow-the-prop rule — is shared with
65
+ * `PropertyRow` (see `./commit.svelte.js`). What stays here is where the
66
+ * DRAFT lives: in the DOM, because this is a contenteditable.
67
+ */
63
68
  // svelte-ignore state_referenced_locally
64
- let savedValue = $state(value);
69
+ const commit_ = commitState(value, config.editMessages);
65
70
  /** What the child snippet renders. Only rewritten while the DOM is not
66
71
  * being typed in — Svelte and the reader must not fight over the node. */
67
72
  // svelte-ignore state_referenced_locally
68
73
  let renderText = $state(value);
69
- let announcement = $state('');
70
74
 
71
- // Follow the prop when the app reloads content underneath us — and only
72
- // then. Diffing against savedValue instead would fire after every save,
73
- // where savedValue has legitimately advanced past the prop, and repaint
74
- // the committed draft with stale copy. Never over a draft being held.
75
- // svelte-ignore state_referenced_locally
76
- let lastPropValue = $state(value);
77
75
  $effect(() => {
78
- if (value !== lastPropValue) {
79
- lastPropValue = value;
80
- if (status === 'idle') {
81
- savedValue = value;
82
- renderText = value;
83
- }
84
- }
76
+ const adopted = commit_.follow(value);
77
+ if (adopted !== null) renderText = adopted;
85
78
  });
86
79
 
87
80
  const active = $derived(edit !== undefined && (adapter?.isEditing ?? false));
@@ -108,35 +101,27 @@
108
101
  range.deleteContents();
109
102
  range.insertNode(document.createTextNode(multiline ? text : text.replace(/\s*\n\s*/g, ' ')));
110
103
  selection.collapseToEnd();
111
- status = 'dirty';
104
+ commit_.markDirty();
112
105
  }
113
106
 
114
107
  function handleInput(): void {
115
- if (status !== 'saving') status = 'dirty';
108
+ commit_.markDirty();
116
109
  }
117
110
 
118
111
  async function commit(element: HTMLElement): Promise<void> {
119
112
  if (!edit || !adapter) return;
120
113
  const draft = textOf(element).trim();
121
- if (draft === savedValue) {
122
- status = 'idle';
114
+ if (draft === commit_.saved) {
115
+ commit_.settle();
123
116
  return;
124
117
  }
125
- status = 'saving';
126
- announcement = config.editMessages.edit_saving();
127
- try {
128
- await adapter.save(edit, draft);
129
- savedValue = draft;
130
- status = 'idle';
131
- announcement = config.editMessages.edit_saved();
132
- } catch {
133
- // Draft stays in the DOM; the reader decides whether to retry.
134
- status = 'error';
135
- announcement = config.editMessages.edit_saveError();
136
- }
118
+ // A failed save leaves the draft in the DOM; the reader decides whether
119
+ // to retry.
120
+ await commit_.commit(draft, () => adapter.save(edit, draft));
137
121
  }
138
122
 
139
123
  function handleBlur(event: FocusEvent): void {
124
+ const status = commit_.status;
140
125
  if (status === 'dirty' || status === 'error') void commit(event.currentTarget as HTMLElement);
141
126
  }
142
127
 
@@ -146,9 +131,8 @@
146
131
  // Svelte's cached text still equals renderText, so a state write
147
132
  // alone cannot repaint a node the reader has mutated: restore the
148
133
  // DOM directly, then settle.
149
- element.textContent = savedValue;
150
- status = 'idle';
151
- announcement = '';
134
+ element.textContent = commit_.saved;
135
+ commit_.revert();
152
136
  element.blur();
153
137
  return;
154
138
  }
@@ -168,7 +152,7 @@
168
152
  role: 'textbox',
169
153
  'aria-label': edit?.label,
170
154
  'aria-multiline': multiline ? 'true' : undefined,
171
- 'data-vit-editing': status,
155
+ 'data-vit-editing': commit_.status,
172
156
  onbeforeinput: handleBeforeInput,
173
157
  oninput: handleInput,
174
158
  onblur: handleBlur,
@@ -181,7 +165,7 @@
181
165
 
182
166
  {@render children(renderText, attrs)}
183
167
  {#if active}
184
- <span class="status" role="status">{announcement}</span>
168
+ <span class="status" role="status">{commit_.announcement}</span>
185
169
  {/if}
186
170
 
187
171
  <style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { PropertyDescriptor } from '../types.js';
2
+ import type { PropertyDescriptor, PropertyValue } from '../types.js';
3
3
  import PropertyRow from './PropertyRow.svelte';
4
4
 
5
5
  /**
@@ -8,7 +8,7 @@
8
8
  * knows nothing about the entity — the descriptors say everything.
9
9
  */
10
10
  interface Props {
11
- rows: { descriptor: PropertyDescriptor; value: string | null }[];
11
+ rows: { descriptor: PropertyDescriptor; value: PropertyValue }[];
12
12
  }
13
13
 
14
14
  let { rows }: Props = $props();
@@ -1,4 +1,4 @@
1
- import type { PropertyDescriptor } from '../types.js';
1
+ import type { PropertyDescriptor, PropertyValue } from '../types.js';
2
2
  /**
3
3
  * The property form an EditFrame's popover holds: one PropertyRow per
4
4
  * entry, each with its own independent commit lifecycle. The component
@@ -7,7 +7,7 @@ import type { PropertyDescriptor } from '../types.js';
7
7
  interface Props {
8
8
  rows: {
9
9
  descriptor: PropertyDescriptor;
10
- value: string | null;
10
+ value: PropertyValue;
11
11
  }[];
12
12
  }
13
13
  declare const EditPanel: import("svelte").Component<Props, {}, "">;
@@ -4,7 +4,9 @@
4
4
  import Icon from '../../components/ui/Icon.svelte';
5
5
  import Modal from '../../components/ui/Modal.svelte';
6
6
  import { getUiConfig } from '../../config/context.js';
7
+ import type { ParameterlessKey } from '../../config/types.js';
7
8
  import { getEditAdapter } from '../context.js';
9
+ import { propertyValue } from '../commit.svelte.js';
8
10
  import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
9
11
  import ConfirmDialog from './ConfirmDialog.svelte';
10
12
 
@@ -31,7 +33,11 @@
31
33
  href: { descriptor: PropertyDescriptor | undefined; value: string };
32
34
  /**
33
35
  * Extra property rows under Adreça — a menu link's Ordre. Labelled from
34
- * their descriptors, committed on Desa only when changed.
36
+ * their descriptors, committed on Desa only when changed, and rendered
37
+ * BY TYPE: this used to be a bare text input whatever the descriptor
38
+ * said, which made it a second renderer that would silently miss any
39
+ * property type the package grew. An `image` row has no place in a link
40
+ * modal and is the one type refused here.
35
41
  */
36
42
  extras?: { descriptor: PropertyDescriptor; value: string | null }[];
37
43
  /** With `adapter.applyOp`, the modal gains a confirmed Elimina action. */
@@ -57,7 +63,14 @@
57
63
  const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
58
64
  // Every field beyond the text needs both a place and a verb to save with.
59
65
  const editsHref = $derived(href.descriptor !== undefined && adapter?.saveProperty !== undefined);
60
- const editableExtras = $derived(adapter?.saveProperty !== undefined ? (extras ?? []) : []);
66
+ const editableExtras = $derived(
67
+ adapter?.saveProperty !== undefined
68
+ ? (extras ?? []).filter((extra) => extra.descriptor.type !== 'image')
69
+ : []
70
+ );
71
+
72
+ /** A flag state's wording, from the same catalog the site renders. */
73
+ const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
61
74
  const removable = $derived(removeOp !== undefined && adapter?.applyOp !== undefined);
62
75
 
63
76
  let open = $state(false);
@@ -104,7 +117,8 @@
104
117
  for (const [index, extra] of editableExtras.entries()) {
105
118
  const next = draftExtras[index].trim();
106
119
  if (next === (extra.value ?? '')) continue;
107
- await adapter.saveProperty!(extra.descriptor, next);
120
+ // A flag extra hands the adapter a BOOLEAN, like a panel row does.
121
+ await adapter.saveProperty!(extra.descriptor, propertyValue(extra.descriptor, next));
108
122
  draftExtras[index] = next;
109
123
  }
110
124
  open = false;
@@ -167,7 +181,25 @@
167
181
  {#each editableExtras as extra, index (index)}
168
182
  <label>
169
183
  <span>{extra.descriptor.label}</span>
170
- <input type="text" bind:value={draftExtras[index]} disabled={saving} />
184
+ {#if extra.descriptor.type === 'select'}
185
+ <select bind:value={draftExtras[index]} disabled={saving}>
186
+ {#each extra.descriptor.options ?? [] as option (option.value)}
187
+ <option value={option.value}>{option.label}</option>
188
+ {/each}
189
+ </select>
190
+ {:else if extra.descriptor.type === 'flag'}
191
+ <select bind:value={draftExtras[index]} disabled={saving}>
192
+ <option value="true">{wording(extra.descriptor.on ?? 'status_published')}</option>
193
+ <option value="false">{wording(extra.descriptor.off ?? 'status_draft')}</option>
194
+ </select>
195
+ {:else}
196
+ <input
197
+ type={extra.descriptor.type === 'date' ? 'date' : 'text'}
198
+ placeholder={extra.descriptor.placeholder}
199
+ bind:value={draftExtras[index]}
200
+ disabled={saving}
201
+ />
202
+ {/if}
171
203
  </label>
172
204
  {/each}
173
205
  </div>
@@ -29,7 +29,11 @@ interface Props {
29
29
  };
30
30
  /**
31
31
  * Extra property rows under Adreça — a menu link's Ordre. Labelled from
32
- * their descriptors, committed on Desa only when changed.
32
+ * their descriptors, committed on Desa only when changed, and rendered
33
+ * BY TYPE: this used to be a bare text input whatever the descriptor
34
+ * said, which made it a second renderer that would silently miss any
35
+ * property type the package grew. An `image` row has no place in a link
36
+ * modal and is the one type refused here.
33
37
  */
34
38
  extras?: {
35
39
  descriptor: PropertyDescriptor;
@@ -1,20 +1,28 @@
1
1
  <script lang="ts">
2
2
  import { getUiConfig } from '../../config/context.js';
3
+ import type { ParameterlessKey } from '../../config/types.js';
3
4
  import { getEditAdapter } from '../context.js';
4
- import type { PropertyDescriptor } from '../types.js';
5
+ import { commitState, propertyValue } from '../commit.svelte.js';
6
+ import type { PropertyDescriptor, PropertyValue } from '../types.js';
5
7
 
6
8
  /**
7
9
  * One property of an EditPanel: label, a control by `descriptor.type`, and
8
- * its own commit lifecycle — Editable's state machine, panel-shaped. Each
9
- * row commits on change/blur independently; a failed save keeps the draft
10
- * in the control so nothing typed is lost.
10
+ * its own commit lifecycle — literally Editable's now, through
11
+ * `../commit.svelte.js`, where it used to be a second copy of the same
12
+ * four states, the same follow-the-prop effect and the same three
13
+ * announcements. Each row commits on change/blur independently; a failed
14
+ * save keeps the draft in the control so nothing typed is lost.
11
15
  *
12
16
  * The row assumes an adapter with `saveProperty` exists — EditPanel only
13
17
  * renders when the frame's triple gate already established both.
18
+ *
19
+ * The control state is text throughout — a flag's boolean rides as
20
+ * 'true'/'false' in its <select> and becomes a boolean again only at the
21
+ * adapter boundary, so one draft/saved pair serves every type.
14
22
  */
15
23
  interface Props {
16
24
  descriptor: PropertyDescriptor;
17
- value: string | null;
25
+ value: PropertyValue;
18
26
  }
19
27
 
20
28
  let { descriptor, value }: Props = $props();
@@ -22,57 +30,47 @@
22
30
  const adapter = getEditAdapter();
23
31
  const config = getUiConfig();
24
32
 
25
- let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
26
- let announcement = $state('');
27
- /** Last persisted value, what a failed draft is measured against. */
33
+ /** The control's reading of a property value. */
34
+ const asText = (next: PropertyValue): string =>
35
+ typeof next === 'boolean' ? String(next) : (next ?? '');
36
+
37
+ /** A flag state's wording, from the same catalog the site renders. */
38
+ const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
39
+
28
40
  // svelte-ignore state_referenced_locally
29
- let savedValue = $state(value ?? '');
41
+ const commit_ = commitState(asText(value), config.editMessages);
30
42
  // svelte-ignore state_referenced_locally
31
- let draft = $state(value ?? '');
43
+ let draft = $state(asText(value));
32
44
  let fileInput: HTMLInputElement | undefined = $state();
33
45
 
34
- // Follow the prop only while idle — never repaint a held draft (the
35
- // Editable rule).
36
- // svelte-ignore state_referenced_locally
37
- let lastPropValue = $state(value ?? '');
38
46
  $effect(() => {
39
- const next = value ?? '';
40
- if (next !== lastPropValue) {
41
- lastPropValue = next;
42
- if (status === 'idle') {
43
- savedValue = next;
44
- draft = next;
45
- }
46
- }
47
+ const adopted = commit_.follow(asText(value));
48
+ if (adopted !== null) draft = adopted;
47
49
  });
48
50
 
49
- async function persist(next: string | null): Promise<void> {
51
+ async function persist(next: PropertyValue): Promise<void> {
50
52
  if (!adapter?.saveProperty) return;
51
- status = 'saving';
52
- announcement = config.editMessages.edit_saving();
53
- try {
54
- await adapter.saveProperty(descriptor, next);
55
- savedValue = next ?? '';
56
- draft = next ?? '';
57
- status = 'idle';
58
- announcement = config.editMessages.edit_saved();
59
- } catch {
60
- // Draft stays in the control; the editor decides whether to retry.
61
- status = 'error';
62
- announcement = config.editMessages.edit_saveError();
63
- }
53
+ const landed = await commit_.commit(asText(next), () =>
54
+ adapter.saveProperty!(descriptor, next)
55
+ );
56
+ // Draft stays in the control on failure; the editor decides whether to
57
+ // retry. On success it takes the value the adapter actually stored.
58
+ if (landed) draft = asText(next);
64
59
  }
65
60
 
66
61
  function commit(): void {
67
62
  const trimmed = draft.trim();
68
- if (trimmed === savedValue) {
69
- status = 'idle';
63
+ if (trimmed === commit_.saved) {
64
+ commit_.settle();
65
+ return;
66
+ }
67
+ if (descriptor.type === 'flag') {
68
+ void persist(propertyValue(descriptor, trimmed));
70
69
  return;
71
70
  }
72
71
  if (trimmed === '') {
73
72
  if (!descriptor.nullable) {
74
- status = 'error';
75
- announcement = config.editMessages.edit_emptyRequired();
73
+ commit_.refuse(config.editMessages.edit_emptyRequired());
76
74
  return;
77
75
  }
78
76
  void persist(null);
@@ -82,20 +80,21 @@
82
80
  }
83
81
 
84
82
  function markDirty(): void {
85
- if (status !== 'saving') status = 'dirty';
83
+ commit_.markDirty();
86
84
  }
87
85
 
88
86
  async function upload(event: Event): Promise<void> {
89
87
  const file = (event.currentTarget as HTMLInputElement).files?.[0];
90
88
  if (!file || !adapter?.uploadImage) return;
91
- status = 'saving';
92
- announcement = config.editMessages.edit_saving();
93
89
  try {
94
- const path = await adapter.uploadImage(descriptor, file);
95
- await persist(path);
96
- } catch {
97
- status = 'error';
98
- announcement = config.editMessages.edit_saveError();
90
+ // The upload and the write are ONE gesture to the editor, so the
91
+ // upload runs inside the same commit: a failed upload reports as a
92
+ // failed save, which is what it is from the panel's side.
93
+ await commit_.commit(asText(value), async () => {
94
+ const path = await adapter.uploadImage!(descriptor, file);
95
+ await adapter.saveProperty!(descriptor, path);
96
+ draft = asText(path);
97
+ });
99
98
  } finally {
100
99
  if (fileInput) fileInput.value = '';
101
100
  }
@@ -111,7 +110,7 @@
111
110
  {#if descriptor.type === 'select'}
112
111
  <select
113
112
  {id}
114
- data-vit-editing={status}
113
+ data-vit-editing={commit_.status}
115
114
  value={draft}
116
115
  onchange={(event) => {
117
116
  draft = event.currentTarget.value;
@@ -122,14 +121,29 @@
122
121
  <option value={option.value}>{option.label}</option>
123
122
  {/each}
124
123
  </select>
124
+ {:else if descriptor.type === 'flag'}
125
+ <!-- Two states, worded from the catalog: the host names the pair
126
+ (published/draft, open/closed), the adapter receives a boolean. -->
127
+ <select
128
+ {id}
129
+ data-vit-editing={commit_.status}
130
+ value={draft}
131
+ onchange={(event) => {
132
+ draft = event.currentTarget.value;
133
+ commit();
134
+ }}
135
+ >
136
+ <option value="true">{wording(descriptor.on ?? 'status_published')}</option>
137
+ <option value="false">{wording(descriptor.off ?? 'status_draft')}</option>
138
+ </select>
125
139
  {:else if descriptor.type === 'image'}
126
- {#if savedValue}
127
- <img class="thumb" src={savedValue} alt="" />
140
+ {#if commit_.saved}
141
+ <img class="thumb" src={commit_.saved} alt="" />
128
142
  {/if}
129
143
  <input
130
144
  {id}
131
145
  type="text"
132
- data-vit-editing={status}
146
+ data-vit-editing={commit_.status}
133
147
  placeholder={descriptor.placeholder}
134
148
  bind:value={draft}
135
149
  oninput={markDirty}
@@ -152,7 +166,7 @@
152
166
  <input
153
167
  {id}
154
168
  type={descriptor.type === 'date' ? 'date' : descriptor.type === 'url' ? 'url' : 'text'}
155
- data-vit-editing={status}
169
+ data-vit-editing={commit_.status}
156
170
  placeholder={descriptor.placeholder}
157
171
  bind:value={draft}
158
172
  oninput={markDirty}
@@ -160,7 +174,7 @@
160
174
  />
161
175
  {/if}
162
176
 
163
- {#if descriptor.nullable && savedValue !== ''}
177
+ {#if descriptor.nullable && commit_.saved !== ''}
164
178
  <button
165
179
  type="button"
166
180
  class="aux"
@@ -173,7 +187,7 @@
173
187
  </button>
174
188
  {/if}
175
189
 
176
- <span class="status" role="status">{announcement}</span>
190
+ <span class="status" role="status">{commit_.announcement}</span>
177
191
  </div>
178
192
 
179
193
  <style>
@@ -1,16 +1,22 @@
1
- import type { PropertyDescriptor } from '../types.js';
1
+ import type { PropertyDescriptor, PropertyValue } from '../types.js';
2
2
  /**
3
3
  * One property of an EditPanel: label, a control by `descriptor.type`, and
4
- * its own commit lifecycle — Editable's state machine, panel-shaped. Each
5
- * row commits on change/blur independently; a failed save keeps the draft
6
- * in the control so nothing typed is lost.
4
+ * its own commit lifecycle — literally Editable's now, through
5
+ * `../commit.svelte.js`, where it used to be a second copy of the same
6
+ * four states, the same follow-the-prop effect and the same three
7
+ * announcements. Each row commits on change/blur independently; a failed
8
+ * save keeps the draft in the control so nothing typed is lost.
7
9
  *
8
10
  * The row assumes an adapter with `saveProperty` exists — EditPanel only
9
11
  * renders when the frame's triple gate already established both.
12
+ *
13
+ * The control state is text throughout — a flag's boolean rides as
14
+ * 'true'/'false' in its <select> and becomes a boolean again only at the
15
+ * adapter boundary, so one draft/saved pair serves every type.
10
16
  */
11
17
  interface Props {
12
18
  descriptor: PropertyDescriptor;
13
- value: string | null;
19
+ value: PropertyValue;
14
20
  }
15
21
  declare const PropertyRow: import("svelte").Component<Props, {}, "">;
16
22
  type PropertyRow = ReturnType<typeof PropertyRow>;
@@ -0,0 +1,49 @@
1
+ import type { CollectionRef, EntityOp } from './types.js';
2
+ type CreateOp = Extract<EntityOp, {
3
+ kind: 'create';
4
+ }>;
5
+ type RemoveOp = Extract<EntityOp, {
6
+ kind: 'remove';
7
+ }>;
8
+ /** An edit map a list may inject removal into. */
9
+ export interface RemovableMap {
10
+ /** Set by the list from its `collection` — removal of this row. */
11
+ removeOp?: RemoveOp;
12
+ }
13
+ /**
14
+ * The structural half of one editable LIST, as its template reads it.
15
+ * Both members answer `undefined` while the collection is not live, so the
16
+ * template's `{#if}` is the whole gate.
17
+ */
18
+ export interface CollectionEditing<Row, Map extends RemovableMap> {
19
+ /** The create op for the add slot at the end of the list. */
20
+ readonly add: CreateOp | undefined;
21
+ /** The create op for an add slot anchored before one row (Timeline). */
22
+ addBefore(id: string | number): CreateOp | undefined;
23
+ /**
24
+ * The host's map for one row, plus the remove op the LIST injects — lists
25
+ * own identity, so a host never spells a removal. Rows without an `id`
26
+ * keep the host's map untouched: a remove op needs an identity, and
27
+ * read-only hosts never pass one.
28
+ */
29
+ mapFor(row: Row): Map | undefined;
30
+ }
31
+ /**
32
+ * What every list with a `collection` prop used to repeat — Timeline,
33
+ * CollaboratorList, JobList, Nav and Footer each carried the triple gate
34
+ * (collection named ∧ adapter editing ∧ `applyOp` present) and the same
35
+ * remove-op injection, five times verbatim. One helper, one place to keep
36
+ * the rule: a structural affordance renders only when all three hold.
37
+ *
38
+ * `read` is a thunk over the list's props so both stay reactive; the
39
+ * adapter comes from context, so call this during component init like
40
+ * `getEditAdapter` itself.
41
+ */
42
+ export declare function collectionEditing<Row extends {
43
+ id?: string | number;
44
+ }, Map extends RemovableMap>(read: () => {
45
+ collection: CollectionRef | undefined;
46
+ /** The host's per-row map (`editFor`, or Nav/Footer's `propertiesFor`). */
47
+ editFor: ((row: Row) => Map | undefined) | undefined;
48
+ }): CollectionEditing<Row, Map>;
49
+ export {};
@@ -0,0 +1,39 @@
1
+ import { getEditAdapter } from './context.js';
2
+ /**
3
+ * What every list with a `collection` prop used to repeat — Timeline,
4
+ * CollaboratorList, JobList, Nav and Footer each carried the triple gate
5
+ * (collection named ∧ adapter editing ∧ `applyOp` present) and the same
6
+ * remove-op injection, five times verbatim. One helper, one place to keep
7
+ * the rule: a structural affordance renders only when all three hold.
8
+ *
9
+ * `read` is a thunk over the list's props so both stay reactive; the
10
+ * adapter comes from context, so call this during component init like
11
+ * `getEditAdapter` itself.
12
+ */
13
+ export function collectionEditing(read) {
14
+ const adapter = getEditAdapter();
15
+ const live = $derived.by(() => {
16
+ const { collection } = read();
17
+ return collection !== undefined &&
18
+ (adapter?.isEditing ?? false) &&
19
+ adapter?.applyOp !== undefined
20
+ ? collection
21
+ : undefined;
22
+ });
23
+ return {
24
+ get add() {
25
+ return live ? { kind: 'create', collection: live } : undefined;
26
+ },
27
+ addBefore(id) {
28
+ return live
29
+ ? { kind: 'create', collection: live, anchor: { id, placement: 'before' } }
30
+ : undefined;
31
+ },
32
+ mapFor(row) {
33
+ const map = read().editFor?.(row);
34
+ if (!live || row.id === undefined)
35
+ return map;
36
+ return { ...map, removeOp: { kind: 'remove', collection: live, id: row.id } };
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,58 @@
1
+ import type { EditMessages } from '../config/edit-messages.js';
2
+ import type { PropertyDescriptor, PropertyValue } from './types.js';
3
+ /**
4
+ * The commit lifecycle of ONE editable value — the state machine
5
+ * `Editable` and `PropertyRow` both ran, in one place.
6
+ *
7
+ * They implemented it twice: the same four states, the same "follow the prop
8
+ * only while idle" effect with the same `lastPropValue` trick, and the same
9
+ * three announcements read from the same three message keys. The inline
10
+ * editor's suite covered one of them, so a bug in commit-or-rollback
11
+ * semantics had two homes and one test surface. `LinkEdit` is deliberately
12
+ * NOT a caller: a modal that batch-commits several halves and reports a
13
+ * partial failure is a different shape, not a simpler version of this one.
14
+ *
15
+ * Where the draft LIVES stays the caller's business — the DOM owns it for a
16
+ * `contenteditable`, a `$state` string owns it for a panel control — which is
17
+ * why this holds `saved` and the status but never the draft.
18
+ */
19
+ export type CommitStatus = 'idle' | 'dirty' | 'saving' | 'error';
20
+ export interface CommitState<T> {
21
+ /** What the affordance paints (`data-vit-editing`). */
22
+ readonly status: CommitStatus;
23
+ /** The visually hidden `role="status"` line. */
24
+ readonly announcement: string;
25
+ /** The last persisted value: what a revert restores and a commit diffs against. */
26
+ readonly saved: T;
27
+ /** Typing began. Never overrides an in-flight save. */
28
+ markDirty(): void;
29
+ /** Back to rest without a write — an unchanged draft. */
30
+ settle(): void;
31
+ /** Escape: back to rest AND silent, the announcement withdrawn with the draft. */
32
+ revert(): void;
33
+ /** Refuse before any write, with its own reason (an emptied required field). */
34
+ refuse(announcement: string): void;
35
+ /**
36
+ * Runs one write. Announces each phase, advances `saved` on success, and
37
+ * leaves the state at `'error'` on rejection so the caller's draft can stay
38
+ * on screen. Answers whether it landed.
39
+ */
40
+ commit(next: T, write: () => Promise<void>): Promise<boolean>;
41
+ /**
42
+ * Adopt a value the app reloaded underneath us — and only while idle.
43
+ * Diffing against `saved` instead would fire after every save, where
44
+ * `saved` has legitimately advanced past the prop, and repaint the
45
+ * committed draft with stale copy. Never over a draft being held.
46
+ *
47
+ * Answers the value the caller should render, or `null` for "leave it".
48
+ */
49
+ follow(next: T): T | null;
50
+ }
51
+ export declare function commitState<T>(initial: T, messages: EditMessages): CommitState<T>;
52
+ /**
53
+ * A control's string draft as the value the ADAPTER takes. Every control's
54
+ * state is text — a flag's boolean rides as `'true'`/`'false'` in its
55
+ * `<select>` — and this is the one place it becomes a boolean again, so the
56
+ * panel row and the link modal cannot disagree about the boundary.
57
+ */
58
+ export declare function propertyValue(descriptor: PropertyDescriptor, draft: string): PropertyValue;