@vit-foundation/ui 0.14.0 → 0.16.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 +21 -42
  5. package/dist/components/layout/Footer.svelte.d.ts +1 -1
  6. package/dist/components/layout/Nav.svelte +29 -46
  7. package/dist/components/layout/Nav.svelte.d.ts +4 -3
  8. package/dist/components/layout/site-link-edit.d.ts +5 -4
  9. package/dist/components/projects/ProjectCard.svelte +2 -2
  10. package/dist/components/projects/ProjectCard.svelte.d.ts +1 -1
  11. package/dist/components/team/CollaboratorList.svelte +10 -16
  12. package/dist/components/timeline/Timeline.svelte +10 -23
  13. package/dist/components/timeline/TimelineMilestone.svelte +2 -2
  14. package/dist/components/timeline/TimelineMilestone.svelte.d.ts +1 -1
  15. package/dist/components/weeklies/WeeklieCard.svelte +2 -2
  16. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +1 -1
  17. package/dist/config/index.d.ts +1 -1
  18. package/dist/config/messages.js +5 -1
  19. package/dist/config/types.d.ts +35 -3
  20. package/dist/content-components.d.ts +5 -1
  21. package/dist/content-components.js +4 -1
  22. package/dist/edit/chrome/EditPanel.svelte +2 -2
  23. package/dist/edit/chrome/EditPanel.svelte.d.ts +2 -2
  24. package/dist/edit/chrome/LinkEdit.svelte +85 -6
  25. package/dist/edit/chrome/LinkEdit.svelte.d.ts +15 -3
  26. package/dist/edit/chrome/PropertyRow.svelte +40 -9
  27. package/dist/edit/chrome/PropertyRow.svelte.d.ts +6 -2
  28. package/dist/edit/collection.svelte.d.ts +49 -0
  29. package/dist/edit/collection.svelte.js +39 -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
@@ -142,7 +142,39 @@ export interface UiMessages {
142
142
  category_education(): string;
143
143
  category_collaboration(): string;
144
144
  category_press(): string;
145
+ /**
146
+ * Editorial state, as the panel's `flag` rows word it: a card's
147
+ * published/draft pair, an opening's open/closed pair. Catalog keys rather
148
+ * than editor strings because the CMS edits them per locale like any other
149
+ * wording — and required, like every key but the *Href pair, because
150
+ * `messages` is all-or-nothing (see ./messages.js).
151
+ */
152
+ status_published(): string;
153
+ status_draft(): string;
154
+ status_open(): string;
155
+ status_closed(): string;
145
156
  }
157
+ /**
158
+ * The catalog keys whose message takes NO parameters — the only ones edit
159
+ * mode may offer as text, inline or in a panel: editing the RENDERED text of
160
+ * a parameterized message (`pagination_status`, `weeklie_number`) would
161
+ * overwrite its template with one interpolation. Derived from the message
162
+ * signatures, so the rule is checked where a key is named; the optional
163
+ * *Href pair qualifies like any other zero-argument message.
164
+ */
165
+ export type ParameterlessKey = {
166
+ [K in keyof UiMessages]-?: NonNullable<UiMessages[K]> extends () => string ? K : never;
167
+ }[keyof UiMessages];
168
+ /** The complement: keys whose message interpolates something. */
169
+ export type ParameterizedKey = Exclude<keyof UiMessages, ParameterlessKey>;
170
+ /**
171
+ * A key a chrome PROPERTY may name: any string but one of the package's own
172
+ * parameterized keys. Wider than `ParameterlessKey` on purpose — the site's
173
+ * catalog has wording the components never render themselves (a search
174
+ * placeholder is the host's), and that key the host vouches for; the
175
+ * package refuses only what it can check.
176
+ */
177
+ export type NotParameterized<K extends string> = K extends ParameterizedKey ? never : K;
146
178
  /**
147
179
  * Everything the components read from their host app, resolved. Apps provide
148
180
  * a partial (`UiConfigInput`) through UiProvider; components read this
@@ -179,10 +211,10 @@ export interface UiConfig {
179
211
  * Edit descriptor for one of THIS config's message strings, by catalog
180
212
  * key — the interface-wording half of edit mode. Optional and undefined by
181
213
  * default, so a read-only app (and every story/test) renders the messages
182
- * as plain text. A CMS-shaped host supplies it alongside an EditAdapter;
183
- * components offer only their parameterless, plain-text message sites.
214
+ * as plain text. A CMS-shaped host supplies it alongside an EditAdapter.
215
+ * The key type is the rule: only a `ParameterlessKey` may edit as text.
184
216
  */
185
- messageEdit?: (key: string) => EditDescriptor | undefined;
217
+ messageEdit?: (key: ParameterlessKey) => EditDescriptor | undefined;
186
218
  }
187
219
  /** What an app hands UiProvider: any subset; the rest keeps package defaults. */
188
220
  export interface UiConfigInput {
@@ -17,6 +17,10 @@ export type { MilestoneEditMap } from './components/timeline/TimelineMilestone.s
17
17
  export { default as WeeklieCard } from './components/weeklies/WeeklieCard.svelte';
18
18
  export type { WeeklyEditMap } from './components/weeklies/WeeklieCard.svelte';
19
19
  export * from './content/index.js';
20
- export { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from './utils/milestones.js';
20
+ export { MILESTONE_CATEGORY_COLOR, matchesMilestoneFilter, milestoneCategoryLabel } from './utils/milestones.js';
21
+ export { createUrlFilters } from './utils/url-filters.svelte.js';
22
+ export type { UrlFilters, UrlFiltersConfig } from './utils/url-filters.svelte.js';
23
+ export { createWeeklyList, WEEKLY_LIST_DEFAULTS } from './utils/weekly-list.svelte.js';
24
+ export type { WeeklyList, WeeklyListConfig, WeeklyListFilters, WeeklyListPage, WeeklyListServerData } from './utils/weekly-list.svelte.js';
21
25
  export { contactCategoryLabel } from './utils/contact.js';
22
26
  export { formatDate, yearOf } from './utils/dates.js';
@@ -12,6 +12,9 @@ export { default as Timeline } from './components/timeline/Timeline.svelte';
12
12
  export { default as TimelineMilestone } from './components/timeline/TimelineMilestone.svelte';
13
13
  export { default as WeeklieCard } from './components/weeklies/WeeklieCard.svelte';
14
14
  export * from './content/index.js';
15
- export { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from './utils/milestones.js';
15
+ export { MILESTONE_CATEGORY_COLOR, matchesMilestoneFilter, milestoneCategoryLabel } from './utils/milestones.js';
16
+ // The two list rules: URL-mirrored filters, and the weeklies index over them.
17
+ export { createUrlFilters } from './utils/url-filters.svelte.js';
18
+ export { createWeeklyList, WEEKLY_LIST_DEFAULTS } from './utils/weekly-list.svelte.js';
16
19
  export { contactCategoryLabel } from './utils/contact.js';
17
20
  export { formatDate, yearOf } from './utils/dates.js';
@@ -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, {}, "">;
@@ -5,7 +5,8 @@
5
5
  import Modal from '../../components/ui/Modal.svelte';
6
6
  import { getUiConfig } from '../../config/context.js';
7
7
  import { getEditAdapter } from '../context.js';
8
- import type { EditDescriptor, PropertyDescriptor } from '../types.js';
8
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
9
+ import ConfirmDialog from './ConfirmDialog.svelte';
9
10
 
10
11
  /**
11
12
  * A LINK edited whole: one modal for its text and its destination, instead
@@ -18,45 +19,67 @@
18
19
  * opens the modal. Desa commits ONLY the halves that changed — the text
19
20
  * through `adapter.save`, the destination through `adapter.saveProperty` —
20
21
  * so a failure on one keeps the other's success and the modal stays open
21
- * with the reason. Without an adapter or a descriptor the control renders
22
- * untouched, byte-identical to a read-only build.
22
+ * with the reason. Extra rows (a menu link's Ordre) and a confirmed
23
+ * Elimina ride in the same modal when the host supplies them. Without an
24
+ * adapter or a descriptor the control renders untouched, byte-identical to
25
+ * a read-only build.
23
26
  */
24
27
  interface Props {
25
28
  /** Inline-label half; undefined leaves the control alone. */
26
29
  text: { edit: EditDescriptor | undefined; value: string };
27
30
  /** Destination half; undefined hides the Adreça field. */
28
31
  href: { descriptor: PropertyDescriptor | undefined; value: string };
32
+ /**
33
+ * Extra property rows under Adreça — a menu link's Ordre. Labelled from
34
+ * their descriptors, committed on Desa only when changed.
35
+ */
36
+ extras?: { descriptor: PropertyDescriptor; value: string | null }[];
37
+ /** With `adapter.applyOp`, the modal gains a confirmed Elimina action. */
38
+ removeOp?: EntityOp;
29
39
  /** Accessible name for the modal, e.g. the link's current text. */
30
40
  label?: string;
31
41
  control: Snippet;
32
42
  }
33
43
 
34
- let { text, href, label = undefined, control }: Props = $props();
44
+ let {
45
+ text,
46
+ href,
47
+ extras = undefined,
48
+ removeOp = undefined,
49
+ label = undefined,
50
+ control
51
+ }: Props = $props();
35
52
 
36
53
  const adapter = getEditAdapter();
37
54
  const config = getUiConfig();
38
55
  const messages = $derived(config.editMessages);
39
56
 
40
57
  const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
41
- // The Adreça field needs both a place to save to and a verb to save with.
58
+ // Every field beyond the text needs both a place and a verb to save with.
42
59
  const editsHref = $derived(href.descriptor !== undefined && adapter?.saveProperty !== undefined);
60
+ const editableExtras = $derived(adapter?.saveProperty !== undefined ? (extras ?? []) : []);
61
+ const removable = $derived(removeOp !== undefined && adapter?.applyOp !== undefined);
43
62
 
44
63
  let open = $state(false);
45
64
  let draftText = $state('');
46
65
  let draftHref = $state('');
66
+ let draftExtras = $state<string[]>([]);
47
67
  let saving = $state(false);
68
+ let confirmingRemove = $state(false);
69
+ let removing = $state(false);
48
70
  let error = $state<string | null>(null);
49
71
 
50
72
  function show(): void {
51
73
  draftText = text.value;
52
74
  draftHref = href.value;
75
+ draftExtras = editableExtras.map((extra) => extra.value ?? '');
53
76
  error = null;
54
77
  open = true;
55
78
  }
56
79
 
57
80
  /** Escape/Cancel·la/backdrop: discard the drafts, keep the stored values. */
58
81
  function cancel(): void {
59
- if (saving) return;
82
+ if (saving || removing) return;
60
83
  open = false;
61
84
  }
62
85
 
@@ -78,6 +101,12 @@
78
101
  await adapter.saveProperty!(href.descriptor, nextHref);
79
102
  draftHref = nextHref;
80
103
  }
104
+ for (const [index, extra] of editableExtras.entries()) {
105
+ const next = draftExtras[index].trim();
106
+ if (next === (extra.value ?? '')) continue;
107
+ await adapter.saveProperty!(extra.descriptor, next);
108
+ draftExtras[index] = next;
109
+ }
81
110
  open = false;
82
111
  } catch (thrown) {
83
112
  error =
@@ -86,6 +115,24 @@
86
115
  saving = false;
87
116
  }
88
117
  }
118
+
119
+ /** Confirmed removal closes everything; a failure reports in THIS modal. */
120
+ async function remove(): Promise<void> {
121
+ if (!adapter?.applyOp || removeOp === undefined) return;
122
+ removing = true;
123
+ error = null;
124
+ try {
125
+ await adapter.applyOp(removeOp);
126
+ confirmingRemove = false;
127
+ open = false;
128
+ } catch (thrown) {
129
+ confirmingRemove = false;
130
+ error =
131
+ thrown instanceof Error && thrown.message ? thrown.message : messages.edit_saveError();
132
+ } finally {
133
+ removing = false;
134
+ }
135
+ }
89
136
  </script>
90
137
 
91
138
  {#if editing}
@@ -117,6 +164,12 @@
117
164
  <input type="text" bind:value={draftHref} disabled={saving} />
118
165
  </label>
119
166
  {/if}
167
+ {#each editableExtras as extra, index (index)}
168
+ <label>
169
+ <span>{extra.descriptor.label}</span>
170
+ <input type="text" bind:value={draftExtras[index]} disabled={saving} />
171
+ </label>
172
+ {/each}
120
173
  </div>
121
174
  {#if error}
122
175
  <p class="error" role="alert">{error}</p>
@@ -124,8 +177,26 @@
124
177
  <div class="actions">
125
178
  <Button pending={saving ? 1 : 0} onclick={() => void save()}>{messages.edit_save()}</Button>
126
179
  <Button variant="ghost" pending={null} onclick={cancel}>{messages.edit_cancel()}</Button>
180
+ {#if removable}
181
+ <span class="danger">
182
+ <Button variant="ghost" pending={null} onclick={() => (confirmingRemove = true)}>
183
+ {messages.edit_remove()}
184
+ </Button>
185
+ </span>
186
+ {/if}
127
187
  </div>
128
188
  </Modal>
189
+ {#if removable}
190
+ <ConfirmDialog
191
+ open={confirmingRemove}
192
+ title={messages.edit_remove()}
193
+ message={messages.edit_removeConfirm({ label: label ?? text.value })}
194
+ confirmLabel={messages.edit_remove()}
195
+ pending={removing}
196
+ onconfirm={() => void remove()}
197
+ oncancel={() => (confirmingRemove = false)}
198
+ />
199
+ {/if}
129
200
  {:else}
130
201
  {@render control()}
131
202
  {/if}
@@ -184,4 +255,12 @@
184
255
  gap: var(--space-2);
185
256
  margin-top: var(--space-4);
186
257
  }
258
+
259
+ /* Elimina sits apart from the commit pair, wearing the error hue — the
260
+ ConfirmDialog token override, reused. */
261
+ .danger {
262
+ margin-left: auto;
263
+ --color-brand: var(--series-8);
264
+ --color-ink: var(--series-8);
265
+ }
187
266
  </style>
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { EditDescriptor, PropertyDescriptor } from '../types.js';
2
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
3
3
  /**
4
4
  * A LINK edited whole: one modal for its text and its destination, instead
5
5
  * of an inline label swap plus a separate property panel — a link's two
@@ -11,8 +11,10 @@ import type { EditDescriptor, PropertyDescriptor } from '../types.js';
11
11
  * opens the modal. Desa commits ONLY the halves that changed — the text
12
12
  * through `adapter.save`, the destination through `adapter.saveProperty` —
13
13
  * so a failure on one keeps the other's success and the modal stays open
14
- * with the reason. Without an adapter or a descriptor the control renders
15
- * untouched, byte-identical to a read-only build.
14
+ * with the reason. Extra rows (a menu link's Ordre) and a confirmed
15
+ * Elimina ride in the same modal when the host supplies them. Without an
16
+ * adapter or a descriptor the control renders untouched, byte-identical to
17
+ * a read-only build.
16
18
  */
17
19
  interface Props {
18
20
  /** Inline-label half; undefined leaves the control alone. */
@@ -25,6 +27,16 @@ interface Props {
25
27
  descriptor: PropertyDescriptor | undefined;
26
28
  value: string;
27
29
  };
30
+ /**
31
+ * Extra property rows under Adreça — a menu link's Ordre. Labelled from
32
+ * their descriptors, committed on Desa only when changed.
33
+ */
34
+ extras?: {
35
+ descriptor: PropertyDescriptor;
36
+ value: string | null;
37
+ }[];
38
+ /** With `adapter.applyOp`, the modal gains a confirmed Elimina action. */
39
+ removeOp?: EntityOp;
28
40
  /** Accessible name for the modal, e.g. the link's current text. */
29
41
  label?: string;
30
42
  control: Snippet;
@@ -1,7 +1,8 @@
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 type { PropertyDescriptor, PropertyValue } from '../types.js';
5
6
 
6
7
  /**
7
8
  * One property of an EditPanel: label, a control by `descriptor.type`, and
@@ -11,10 +12,14 @@
11
12
  *
12
13
  * The row assumes an adapter with `saveProperty` exists — EditPanel only
13
14
  * renders when the frame's triple gate already established both.
15
+ *
16
+ * The control state is text throughout — a flag's boolean rides as
17
+ * 'true'/'false' in its <select> and becomes a boolean again only at the
18
+ * adapter boundary, so one draft/saved pair serves every type.
14
19
  */
15
20
  interface Props {
16
21
  descriptor: PropertyDescriptor;
17
- value: string | null;
22
+ value: PropertyValue;
18
23
  }
19
24
 
20
25
  let { descriptor, value }: Props = $props();
@@ -22,21 +27,28 @@
22
27
  const adapter = getEditAdapter();
23
28
  const config = getUiConfig();
24
29
 
30
+ /** The control's reading of a property value. */
31
+ const asText = (next: PropertyValue): string =>
32
+ typeof next === 'boolean' ? String(next) : (next ?? '');
33
+
34
+ /** A flag state's wording, from the same catalog the site renders. */
35
+ const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
36
+
25
37
  let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
26
38
  let announcement = $state('');
27
39
  /** Last persisted value, what a failed draft is measured against. */
28
40
  // svelte-ignore state_referenced_locally
29
- let savedValue = $state(value ?? '');
41
+ let savedValue = $state(asText(value));
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
46
  // Follow the prop only while idle — never repaint a held draft (the
35
47
  // Editable rule).
36
48
  // svelte-ignore state_referenced_locally
37
- let lastPropValue = $state(value ?? '');
49
+ let lastPropValue = $state(asText(value));
38
50
  $effect(() => {
39
- const next = value ?? '';
51
+ const next = asText(value);
40
52
  if (next !== lastPropValue) {
41
53
  lastPropValue = next;
42
54
  if (status === 'idle') {
@@ -46,14 +58,14 @@
46
58
  }
47
59
  });
48
60
 
49
- async function persist(next: string | null): Promise<void> {
61
+ async function persist(next: PropertyValue): Promise<void> {
50
62
  if (!adapter?.saveProperty) return;
51
63
  status = 'saving';
52
64
  announcement = config.editMessages.edit_saving();
53
65
  try {
54
66
  await adapter.saveProperty(descriptor, next);
55
- savedValue = next ?? '';
56
- draft = next ?? '';
67
+ savedValue = asText(next);
68
+ draft = asText(next);
57
69
  status = 'idle';
58
70
  announcement = config.editMessages.edit_saved();
59
71
  } catch {
@@ -69,6 +81,10 @@
69
81
  status = 'idle';
70
82
  return;
71
83
  }
84
+ if (descriptor.type === 'flag') {
85
+ void persist(trimmed === 'true');
86
+ return;
87
+ }
72
88
  if (trimmed === '') {
73
89
  if (!descriptor.nullable) {
74
90
  status = 'error';
@@ -122,6 +138,21 @@
122
138
  <option value={option.value}>{option.label}</option>
123
139
  {/each}
124
140
  </select>
141
+ {:else if descriptor.type === 'flag'}
142
+ <!-- Two states, worded from the catalog: the host names the pair
143
+ (published/draft, open/closed), the adapter receives a boolean. -->
144
+ <select
145
+ {id}
146
+ data-vit-editing={status}
147
+ value={draft}
148
+ onchange={(event) => {
149
+ draft = event.currentTarget.value;
150
+ commit();
151
+ }}
152
+ >
153
+ <option value="true">{wording(descriptor.on ?? 'status_published')}</option>
154
+ <option value="false">{wording(descriptor.off ?? 'status_draft')}</option>
155
+ </select>
125
156
  {:else if descriptor.type === 'image'}
126
157
  {#if savedValue}
127
158
  <img class="thumb" src={savedValue} alt="" />
@@ -1,4 +1,4 @@
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
4
  * its own commit lifecycle — Editable's state machine, panel-shaped. Each
@@ -7,10 +7,14 @@ import type { PropertyDescriptor } from '../types.js';
7
7
  *
8
8
  * The row assumes an adapter with `saveProperty` exists — EditPanel only
9
9
  * renders when the frame's triple gate already established both.
10
+ *
11
+ * The control state is text throughout — a flag's boolean rides as
12
+ * 'true'/'false' in its <select> and becomes a boolean again only at the
13
+ * adapter boundary, so one draft/saved pair serves every type.
10
14
  */
11
15
  interface Props {
12
16
  descriptor: PropertyDescriptor;
13
- value: string | null;
17
+ value: PropertyValue;
14
18
  }
15
19
  declare const PropertyRow: import("svelte").Component<Props, {}, "">;
16
20
  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
+ }
@@ -1,13 +1,12 @@
1
- import type { Locale } from '../config/types.js';
1
+ import type { Locale, NotParameterized, ParameterlessKey } from '../config/types.js';
2
2
  import type { CollectionRef, EditableEntity, EditDescriptor, PropertyDescriptor } from './types.js';
3
3
  /** Everything a property descriptor carries beyond its ref. */
4
4
  type PropertySpec = Omit<PropertyDescriptor, 'ref'>;
5
5
  /**
6
6
  * Descriptor for one interface-wording message (a Paraglide catalog key).
7
- * Only parameterless messages are sensibly editable in place — editing the
8
- * RENDERED text of a parameterized one would overwrite its template.
7
+ * The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
9
8
  */
10
- export declare function chromeEdit(key: string, locale: Locale, options?: {
9
+ export declare function chromeEdit(key: ParameterlessKey, locale: Locale, options?: {
11
10
  format?: EditDescriptor['format'];
12
11
  label?: string;
13
12
  }): EditDescriptor;
@@ -38,8 +37,9 @@ export declare function entityProperty(entity: EditableEntity, id: string | numb
38
37
  /**
39
38
  * A panel property over one interface-wording message — how strings that can
40
39
  * never hold a caret (an `<option>` label, an input placeholder) still edit.
40
+ * Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
41
41
  */
42
- export declare function chromeProperty(key: string, spec: PropertySpec): PropertyDescriptor;
42
+ export declare function chromeProperty<K extends string>(key: NotParameterized<K>, spec: PropertySpec): PropertyDescriptor;
43
43
  /** Names one entity collection at one render site. */
44
44
  export declare function collectionOf(entity: EditableEntity, scope?: string): CollectionRef;
45
45
  export {};
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Descriptor for one interface-wording message (a Paraglide catalog key).
3
- * Only parameterless messages are sensibly editable in place — editing the
4
- * RENDERED text of a parameterized one would overwrite its template.
3
+ * The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
5
4
  */
6
5
  export function chromeEdit(key, locale, options) {
7
6
  return { ref: { kind: 'chrome', key }, locale, ...options };
@@ -33,6 +32,7 @@ export function entityProperty(entity, id) {
33
32
  /**
34
33
  * A panel property over one interface-wording message — how strings that can
35
34
  * never hold a caret (an `<option>` label, an input placeholder) still edit.
35
+ * Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
36
36
  */
37
37
  export function chromeProperty(key, spec) {
38
38
  return { ref: { kind: 'chrome', key }, ...spec };
@@ -1,4 +1,6 @@
1
1
  export { getEditAdapter, setEditAdapter } from './context.js';
2
+ export { collectionEditing } from './collection.svelte.js';
3
+ export type { CollectionEditing, RemovableMap } from './collection.svelte.js';
2
4
  export { default as ActionLabel } from './ActionLabel.svelte';
3
5
  export { default as Editable } from './Editable.svelte';
4
6
  export { default as AddSlot } from './chrome/AddSlot.svelte';
@@ -10,4 +12,5 @@ export { default as EditPopover } from './chrome/EditPopover.svelte';
10
12
  export { default as LinkEdit } from './chrome/LinkEdit.svelte';
11
13
  export { chromeEdit, chromeProperty, collectionOf, entityEdit, entityProperty, pageCopyEdit } from './helpers.js';
12
14
  export { localize } from './types.js';
13
- export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType } from './types.js';
15
+ export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType, PropertyValue } from './types.js';
16
+ export type { NotParameterized, ParameterlessKey } from '../config/types.js';
@@ -1,4 +1,5 @@
1
1
  export { getEditAdapter, setEditAdapter } from './context.js';
2
+ export { collectionEditing } from './collection.svelte.js';
2
3
  export { default as ActionLabel } from './ActionLabel.svelte';
3
4
  export { default as Editable } from './Editable.svelte';
4
5
  export { default as AddSlot } from './chrome/AddSlot.svelte';