@vit-foundation/ui 0.11.0 → 0.13.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 (35) hide show
  1. package/dist/chrome.d.ts +1 -0
  2. package/dist/components/account/NewsletterSignup.svelte +19 -3
  3. package/dist/components/contact/ContactForm.svelte +62 -14
  4. package/dist/components/jobs/JobList.svelte +75 -12
  5. package/dist/components/jobs/JobList.svelte.d.ts +19 -2
  6. package/dist/components/layout/Footer.svelte +54 -5
  7. package/dist/components/layout/Footer.svelte.d.ts +6 -1
  8. package/dist/components/layout/Nav.svelte +64 -8
  9. package/dist/components/layout/Nav.svelte.d.ts +6 -1
  10. package/dist/components/layout/site-link-edit.d.ts +17 -0
  11. package/dist/components/layout/site-link-edit.js +1 -0
  12. package/dist/components/projects/ProjectCard.svelte +80 -33
  13. package/dist/components/projects/ProjectCard.svelte.d.ts +11 -2
  14. package/dist/components/team/CollaboratorList.svelte +87 -9
  15. package/dist/components/team/CollaboratorList.svelte.d.ts +26 -0
  16. package/dist/components/team/TeamMemberCard.svelte +41 -19
  17. package/dist/components/team/TeamMemberCard.svelte.d.ts +8 -4
  18. package/dist/components/timeline/TimelineMilestone.svelte +3 -1
  19. package/dist/components/ui/CardMedia.svelte +66 -3
  20. package/dist/components/ui/CardMedia.svelte.d.ts +6 -2
  21. package/dist/components/ui/SearchInput.svelte +31 -10
  22. package/dist/components/ui/SearchInput.svelte.d.ts +7 -0
  23. package/dist/components/weeklies/CommentSection.svelte +20 -3
  24. package/dist/components/weeklies/SortSelect.svelte +41 -11
  25. package/dist/components/weeklies/SortSelect.svelte.d.ts +8 -0
  26. package/dist/components/weeklies/WeeklieCard.svelte +46 -19
  27. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +9 -2
  28. package/dist/config/edit-messages.d.ts +2 -0
  29. package/dist/config/edit-messages.js +1 -0
  30. package/dist/config/types.d.ts +4 -0
  31. package/dist/content/types.d.ts +12 -0
  32. package/dist/edit/chrome/DraftBadge.svelte +30 -0
  33. package/dist/edit/chrome/DraftBadge.svelte.d.ts +18 -0
  34. package/dist/edit/types.d.ts +1 -1
  35. package/package.json +1 -1
package/dist/chrome.d.ts CHANGED
@@ -4,4 +4,5 @@
4
4
  */
5
5
  export { default as Footer } from './components/layout/Footer.svelte';
6
6
  export { default as Nav } from './components/layout/Nav.svelte';
7
+ export type { SiteLinkEditMap } from './components/layout/site-link-edit.js';
7
8
  export { default as PageShell } from './components/layout/PageShell.svelte';
@@ -44,9 +44,23 @@
44
44
  >
45
45
  {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
46
46
  </Editable>
47
- <Link href={withNewsletterIntent('/signup')}>{msg.comments_signupLink()}</Link>
47
+ <ActionLabel
48
+ edit={config.messageEdit?.('comments_signupLink')}
49
+ value={msg.comments_signupLink()}
50
+ >
51
+ {#snippet control()}<Link href={withNewsletterIntent('/signup')}
52
+ >{msg.comments_signupLink()}</Link
53
+ >{/snippet}
54
+ </ActionLabel>
48
55
  ·
49
- <Link href={withNewsletterIntent('/login')}>{msg.comments_loginLink()}</Link>
56
+ <ActionLabel
57
+ edit={config.messageEdit?.('comments_loginLink')}
58
+ value={msg.comments_loginLink()}
59
+ >
60
+ {#snippet control()}<Link href={withNewsletterIntent('/login')}
61
+ >{msg.comments_loginLink()}</Link
62
+ >{/snippet}
63
+ </ActionLabel>
50
64
  </p>
51
65
  {:else if account.newsletterSubscribed}
52
66
  <p class="prompt">
@@ -56,7 +70,9 @@
56
70
  >
57
71
  {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
58
72
  </Editable>
59
- <Link href="/account">{msg.account_navLabel()}</Link>
73
+ <ActionLabel edit={config.messageEdit?.('account_navLabel')} value={msg.account_navLabel()}>
74
+ {#snippet control()}<Link href="/account">{msg.account_navLabel()}</Link>{/snippet}
75
+ </ActionLabel>
60
76
  </p>
61
77
  {:else}
62
78
  <form {...f}>
@@ -20,6 +20,9 @@
20
20
  <script lang="ts">
21
21
  import { getUiConfig } from '../../config/context.js';
22
22
  import ActionLabel from '../../edit/ActionLabel.svelte';
23
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
24
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
25
+ import { chromeProperty } from '../../edit/helpers.js';
23
26
  import { CONTACT_MESSAGE, CONTACT_NAME, EMAIL } from '../../forms/constraints.js';
24
27
  import { CONTACT_CATEGORIES } from '../../content/types.js';
25
28
  import { contactCategoryLabel } from '../../utils/contact.js';
@@ -49,27 +52,72 @@
49
52
  // what order (the host schema derives its enum from CONTACT_CATEGORIES),
50
53
  // and the messages own the copy for each.
51
54
  const categories = CONTACT_CATEGORIES;
55
+
56
+ /**
57
+ * An <option> cannot hold a caret, so the category labels edit through
58
+ * the frame's panel — one text row per category, over the SAME chrome
59
+ * keys the options render. Gated the way the form's other wording is:
60
+ * only where the host exposes `messageEdit` (the CMS mirror); the frame
61
+ * itself further requires an editing adapter with `saveProperty`.
62
+ */
63
+ const categoryRows = $derived(
64
+ config.messageEdit
65
+ ? [
66
+ ...categories.map((category) => ({
67
+ descriptor: chromeProperty(`contact_category_${category}`, {
68
+ type: 'text' as const,
69
+ label: contactCategoryLabel(category, msg)
70
+ }),
71
+ value: contactCategoryLabel(category, msg)
72
+ })),
73
+ // Post-submit feedback never shows while editing (the mirror's
74
+ // forms are inert), so these two edit here or nowhere in place.
75
+ {
76
+ descriptor: chromeProperty('contact_success', {
77
+ type: 'text' as const,
78
+ label: msg.contact_success()
79
+ }),
80
+ value: msg.contact_success()
81
+ },
82
+ {
83
+ descriptor: chromeProperty('contact_invalid', {
84
+ type: 'text' as const,
85
+ label: msg.contact_invalid()
86
+ }),
87
+ value: msg.contact_invalid()
88
+ }
89
+ ]
90
+ : []
91
+ );
92
+ const categoryFrameSpec = $derived(
93
+ categoryRows.length > 0 ? { label: msg.contact_categoryLabel(), hasPanel: true } : undefined
94
+ );
52
95
  </script>
53
96
 
54
97
  {#if f.result?.ok}
55
98
  <FormFeedback kind="success">{msg.contact_success()}</FormFeedback>
56
99
  {:else}
57
100
  <form class="form-stack" {...f}>
58
- <Field
59
- id="contact-category"
60
- label={msg.contact_categoryLabel()}
61
- labelEdit={config.messageEdit?.('contact_categoryLabel')}
62
- field={f.fields.category}
63
- constraint={null}
64
- >
65
- {#snippet children(attrs)}
66
- <select class="control" {...attrs} {...f.fields.category.as('select')}>
67
- {#each categories as category (category)}
68
- <option value={category}>{contactCategoryLabel(category, msg)}</option>
69
- {/each}
70
- </select>
101
+ <EditFrame spec={categoryFrameSpec}>
102
+ {#snippet panel()}
103
+ <EditPanel rows={categoryRows} />
71
104
  {/snippet}
72
- </Field>
105
+ <Field
106
+ id="contact-category"
107
+ label={msg.contact_categoryLabel()}
108
+ labelEdit={config.messageEdit?.('contact_categoryLabel')}
109
+ field={f.fields.category}
110
+ constraint={null}
111
+ >
112
+ {#snippet children(attrs)}
113
+ <select class="control" {...attrs} {...f.fields.category.as('select')}>
114
+ {#each categories as category (category)}
115
+ <option value={category}>{contactCategoryLabel(category, msg)}</option>
116
+ {/each}
117
+ </select>
118
+ {/snippet}
119
+ </Field>
120
+ </EditFrame>
73
121
 
74
122
  <Field
75
123
  id="contact-name"
@@ -1,17 +1,31 @@
1
1
  <script module lang="ts">
2
- import type { EditDescriptor } from '../../edit/types.js';
2
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../../edit/types.js';
3
3
 
4
- /** Which of one opening's localized fields are editable at this render site. */
4
+ /**
5
+ * Which of one opening's fields are editable at this render site:
6
+ * localized text inline, the posting date through the frame's panel.
7
+ */
5
8
  export interface JobEditMap {
6
9
  title?: EditDescriptor;
7
10
  description?: EditDescriptor;
11
+ postedOn?: PropertyDescriptor;
12
+ /** Accessible name for the frame — usually the opening's title. */
13
+ label?: string;
14
+ /** Set by the list from its `collection` — removal of this opening. */
15
+ removeOp?: Extract<EntityOp, { kind: 'remove' }>;
8
16
  }
9
17
  </script>
10
18
 
11
19
  <script lang="ts">
12
20
  import { getUiConfig } from '../../config/context.js';
13
21
  import type { JobOpeningData } from '../../content/types.js';
22
+ import AddSlot from '../../edit/chrome/AddSlot.svelte';
23
+ import DraftBadge from '../../edit/chrome/DraftBadge.svelte';
24
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
25
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
26
+ import { getEditAdapter } from '../../edit/context.js';
14
27
  import Editable from '../../edit/Editable.svelte';
28
+ import type { CollectionRef } from '../../edit/types.js';
15
29
  import DateText from '../ui/DateText.svelte';
16
30
 
17
31
  interface Props {
@@ -21,37 +35,81 @@
21
35
  * own identity. Returning undefined leaves that row read-only.
22
36
  */
23
37
  editFor?: (job: JobOpeningData) => JobEditMap | undefined;
38
+ /**
39
+ * Names the collection the openings belong to. Set alongside an adapter
40
+ * with `applyOp`, it turns on the add slot and each frame's remove —
41
+ * for rows that carry an `id` (removal needs an identity).
42
+ */
43
+ collection?: CollectionRef;
24
44
  }
25
45
 
26
- let { jobs, editFor }: Props = $props();
46
+ let { jobs, editFor, collection }: Props = $props();
27
47
 
28
48
  const config = getUiConfig();
49
+ const adapter = getEditAdapter();
29
50
  const msg = $derived(config.messages);
51
+
52
+ const structural = $derived(
53
+ collection !== undefined && (adapter?.isEditing ?? false) && adapter?.applyOp !== undefined
54
+ );
55
+
56
+ /** The map the host gave, plus removal — the list owns identity. */
57
+ function editMapFor(job: JobOpeningData): JobEditMap | undefined {
58
+ const map = editFor?.(job);
59
+ if (!structural || !collection || job.id === undefined) return map;
60
+ return { ...map, removeOp: { kind: 'remove', collection, id: job.id } };
61
+ }
30
62
  </script>
31
63
 
32
64
  {#if jobs.length === 0}
33
65
  <Editable edit={config.messageEdit?.('jobs_empty')} value={msg.jobs_empty()}>
34
66
  {#snippet children(text, attrs)}<p class="empty" {...attrs}>{text}</p>{/snippet}
35
67
  </Editable>
68
+ {#if structural && collection}
69
+ <AddSlot op={{ kind: 'create', collection }} />
70
+ {/if}
36
71
  <Editable edit={config.messageEdit?.('jobs_newsletterNudge')} value={msg.jobs_newsletterNudge()}>
37
72
  {#snippet children(text, attrs)}<p class="nudge" {...attrs}>{text}</p>{/snippet}
38
73
  </Editable>
39
74
  {:else}
40
75
  <ul>
41
76
  {#each jobs as job (job.slug)}
42
- {@const edit = editFor?.(job)}
77
+ {@const edit = editMapFor(job)}
78
+ {@const rows = [edit?.postedOn && { descriptor: edit.postedOn, value: job.postedOn }].filter(
79
+ (row) => row !== undefined
80
+ )}
43
81
  <li>
44
- <Editable edit={edit?.title} value={job.title}>
45
- {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
46
- </Editable>
47
- <DateText value={job.postedOn} />
48
- {#if job.description}
49
- <Editable edit={edit?.description} value={job.description}>
50
- {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
82
+ <!-- Inside the li — see CollaboratorList. -->
83
+ <EditFrame
84
+ spec={edit && (rows.length > 0 || edit.removeOp)
85
+ ? {
86
+ label: edit.label ?? job.title,
87
+ hasPanel: rows.length > 0,
88
+ removeOp: edit.removeOp
89
+ }
90
+ : undefined}
91
+ >
92
+ {#snippet panel()}
93
+ <EditPanel {rows} />
94
+ {/snippet}
95
+ <Editable edit={edit?.title} value={job.title}>
96
+ {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
51
97
  </Editable>
52
- {/if}
98
+ {#if job.draft}<DraftBadge />{/if}
99
+ <DateText value={job.postedOn} />
100
+ {#if job.description}
101
+ <Editable edit={edit?.description} value={job.description}>
102
+ {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
103
+ </Editable>
104
+ {/if}
105
+ </EditFrame>
53
106
  </li>
54
107
  {/each}
108
+ {#if structural && collection}
109
+ <li class="add-slot">
110
+ <AddSlot op={{ kind: 'create', collection }} />
111
+ </li>
112
+ {/if}
55
113
  </ul>
56
114
  <Editable edit={config.messageEdit?.('jobs_newsletterNudge')} value={msg.jobs_newsletterNudge()}>
57
115
  {#snippet children(text, attrs)}<p class="nudge" {...attrs}>{text}</p>{/snippet}
@@ -91,4 +149,9 @@
91
149
  .nudge {
92
150
  color: var(--color-ink-secondary);
93
151
  }
152
+
153
+ li.add-slot {
154
+ background: none;
155
+ padding: 0;
156
+ }
94
157
  </style>
@@ -1,10 +1,21 @@
1
- import type { EditDescriptor } from '../../edit/types.js';
2
- /** Which of one opening's localized fields are editable at this render site. */
1
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * Which of one opening's fields are editable at this render site:
4
+ * localized text inline, the posting date through the frame's panel.
5
+ */
3
6
  export interface JobEditMap {
4
7
  title?: EditDescriptor;
5
8
  description?: EditDescriptor;
9
+ postedOn?: PropertyDescriptor;
10
+ /** Accessible name for the frame — usually the opening's title. */
11
+ label?: string;
12
+ /** Set by the list from its `collection` — removal of this opening. */
13
+ removeOp?: Extract<EntityOp, {
14
+ kind: 'remove';
15
+ }>;
6
16
  }
7
17
  import type { JobOpeningData } from '../../content/types.js';
18
+ import type { CollectionRef } from '../../edit/types.js';
8
19
  interface Props {
9
20
  jobs: JobOpeningData[];
10
21
  /**
@@ -12,6 +23,12 @@ interface Props {
12
23
  * own identity. Returning undefined leaves that row read-only.
13
24
  */
14
25
  editFor?: (job: JobOpeningData) => JobEditMap | undefined;
26
+ /**
27
+ * Names the collection the openings belong to. Set alongside an adapter
28
+ * with `applyOp`, it turns on the add slot and each frame's remove —
29
+ * for rows that carry an `id` (removal needs an identity).
30
+ */
31
+ collection?: CollectionRef;
15
32
  }
16
33
  declare const JobList: import("svelte").Component<Props, {}, "">;
17
34
  type JobList = ReturnType<typeof JobList>;
@@ -2,9 +2,14 @@
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import type { SiteLink } from '../../config/types.js';
4
4
  import ActionLabel from '../../edit/ActionLabel.svelte';
5
+ import AddSlot from '../../edit/chrome/AddSlot.svelte';
6
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
7
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
8
+ import { getEditAdapter } from '../../edit/context.js';
5
9
  import Editable from '../../edit/Editable.svelte';
6
- import type { EditDescriptor } from '../../edit/types.js';
10
+ import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
7
11
  import Link from '../ui/Link.svelte';
12
+ import type { SiteLinkEditMap } from './site-link-edit.js';
8
13
 
9
14
  /**
10
15
  * The links are a prop rather than a package constant: the host app owns
@@ -15,11 +20,34 @@
15
20
  links: SiteLink[];
16
21
  /** Edit descriptors for the link labels — see Nav. */
17
22
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
23
+ /** Panel rows (href, order) per link — see Nav. */
24
+ propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
25
+ /** Names the links' collection; with `applyOp`, turns on add/remove. */
26
+ collection?: CollectionRef;
18
27
  }
19
28
 
20
- let { links, editFor = undefined }: Props = $props();
29
+ let { links, editFor = undefined, propertiesFor = undefined, collection }: Props = $props();
21
30
 
22
31
  const config = getUiConfig();
32
+ const adapter = getEditAdapter();
33
+
34
+ const structural = $derived(
35
+ collection !== undefined && (adapter?.isEditing ?? false) && adapter?.applyOp !== undefined
36
+ );
37
+
38
+ /** The map the host gave, plus removal — the list owns identity. */
39
+ function editMapFor(link: SiteLink): SiteLinkEditMap | undefined {
40
+ const map = propertiesFor?.(link);
41
+ if (!structural || !collection || link.id === undefined) return map;
42
+ return { ...map, removeOp: { kind: 'remove', collection, id: link.id } };
43
+ }
44
+
45
+ function rowsFor(link: SiteLink, map: SiteLinkEditMap | undefined) {
46
+ return [
47
+ map?.href && { descriptor: map.href, value: link.href },
48
+ map?.order && { descriptor: map.order, value: String(link.order ?? 0) }
49
+ ].filter((row) => row !== undefined);
50
+ }
23
51
 
24
52
  const year = new Date().getFullYear();
25
53
  </script>
@@ -31,12 +59,33 @@
31
59
  </span>
32
60
  <ul>
33
61
  {#each links as link (link.href)}
62
+ {@const map = editMapFor(link)}
63
+ {@const rows = rowsFor(link, map)}
34
64
  <li>
35
- <ActionLabel edit={editFor?.(link)} value={link.label}>
36
- {#snippet control()}<Link href={link.href}>{link.label}</Link>{/snippet}
37
- </ActionLabel>
65
+ <!-- Inside the li, so the list's flex layout never gains a child. -->
66
+ <EditFrame
67
+ spec={map && (rows.length > 0 || map.removeOp)
68
+ ? {
69
+ label: map.label ?? link.label,
70
+ hasPanel: rows.length > 0,
71
+ removeOp: map.removeOp
72
+ }
73
+ : undefined}
74
+ >
75
+ {#snippet panel()}
76
+ <EditPanel {rows} />
77
+ {/snippet}
78
+ <ActionLabel edit={editFor?.(link)} value={link.label}>
79
+ {#snippet control()}<Link href={link.href}>{link.label}</Link>{/snippet}
80
+ </ActionLabel>
81
+ </EditFrame>
38
82
  </li>
39
83
  {/each}
84
+ {#if structural && collection}
85
+ <li>
86
+ <AddSlot op={{ kind: 'create', collection }} />
87
+ </li>
88
+ {/if}
40
89
  </ul>
41
90
  <p class="rights">
42
91
  © {year}
@@ -1,5 +1,6 @@
1
1
  import type { SiteLink } from '../../config/types.js';
2
- import type { EditDescriptor } from '../../edit/types.js';
2
+ import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
3
+ import type { SiteLinkEditMap } from './site-link-edit.js';
3
4
  /**
4
5
  * The links are a prop rather than a package constant: the host app owns
5
6
  * its routes, and Nav and Footer render the same list — the foundation
@@ -9,6 +10,10 @@ interface Props {
9
10
  links: SiteLink[];
10
11
  /** Edit descriptors for the link labels — see Nav. */
11
12
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
13
+ /** Panel rows (href, order) per link — see Nav. */
14
+ propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
15
+ /** Names the links' collection; with `applyOp`, turns on add/remove. */
16
+ collection?: CollectionRef;
12
17
  }
13
18
  declare const Footer: import("svelte").Component<Props, {}, "">;
14
19
  type Footer = ReturnType<typeof Footer>;
@@ -2,10 +2,15 @@
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import type { SiteLink } from '../../config/types.js';
4
4
  import ActionLabel from '../../edit/ActionLabel.svelte';
5
- import type { EditDescriptor } from '../../edit/types.js';
5
+ import AddSlot from '../../edit/chrome/AddSlot.svelte';
6
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
7
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
8
+ import { getEditAdapter } from '../../edit/context.js';
9
+ import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
6
10
  import { isPathUnder } from '../../utils/paths.js';
7
11
  import GhostButton from '../ui/GhostButton.svelte';
8
12
  import Link from '../ui/Link.svelte';
13
+ import type { SiteLinkEditMap } from './site-link-edit.js';
9
14
 
10
15
  interface Props {
11
16
  /**
@@ -25,11 +30,41 @@
25
30
  * text instead of an anchor (see ActionLabel).
26
31
  */
27
32
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
33
+ /** Panel rows (href, order) per link — the label edits via `editFor`. */
34
+ propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
35
+ /** Names the links' collection; with `applyOp`, turns on add/remove. */
36
+ collection?: CollectionRef;
28
37
  }
29
38
 
30
- let { links, account = null, url = undefined, editFor = undefined }: Props = $props();
39
+ let {
40
+ links,
41
+ account = null,
42
+ url = undefined,
43
+ editFor = undefined,
44
+ propertiesFor = undefined,
45
+ collection
46
+ }: Props = $props();
31
47
 
32
48
  const config = getUiConfig();
49
+ const adapter = getEditAdapter();
50
+
51
+ const structural = $derived(
52
+ collection !== undefined && (adapter?.isEditing ?? false) && adapter?.applyOp !== undefined
53
+ );
54
+
55
+ /** The map the host gave, plus removal — the list owns identity. */
56
+ function editMapFor(link: SiteLink): SiteLinkEditMap | undefined {
57
+ const map = propertiesFor?.(link);
58
+ if (!structural || !collection || link.id === undefined) return map;
59
+ return { ...map, removeOp: { kind: 'remove', collection, id: link.id } };
60
+ }
61
+
62
+ function rowsFor(link: SiteLink, map: SiteLinkEditMap | undefined) {
63
+ return [
64
+ map?.href && { descriptor: map.href, value: link.href },
65
+ map?.order && { descriptor: map.order, value: String(link.order ?? 0) }
66
+ ].filter((row) => row !== undefined);
67
+ }
33
68
  const msg = $derived(config.messages);
34
69
 
35
70
  let isMenuOpen = $state(false);
@@ -85,16 +120,37 @@
85
120
  <div class="menu" id="site-menu" class:open={isMenuOpen}>
86
121
  <ul class="links">
87
122
  {#each links as link (link.href)}
123
+ {@const map = editMapFor(link)}
124
+ {@const rows = rowsFor(link, map)}
88
125
  <li>
89
- <ActionLabel edit={editFor?.(link)} value={link.label}>
90
- {#snippet control()}
91
- <Link href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
92
- {link.label}
93
- </Link>
126
+ <!-- Inside the li, so the row's layout never gains a child. -->
127
+ <EditFrame
128
+ spec={map && (rows.length > 0 || map.removeOp)
129
+ ? {
130
+ label: map.label ?? link.label,
131
+ hasPanel: rows.length > 0,
132
+ removeOp: map.removeOp
133
+ }
134
+ : undefined}
135
+ >
136
+ {#snippet panel()}
137
+ <EditPanel {rows} />
94
138
  {/snippet}
95
- </ActionLabel>
139
+ <ActionLabel edit={editFor?.(link)} value={link.label}>
140
+ {#snippet control()}
141
+ <Link href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
142
+ {link.label}
143
+ </Link>
144
+ {/snippet}
145
+ </ActionLabel>
146
+ </EditFrame>
96
147
  </li>
97
148
  {/each}
149
+ {#if structural && collection}
150
+ <li>
151
+ <AddSlot op={{ kind: 'create', collection }} />
152
+ </li>
153
+ {/if}
98
154
  </ul>
99
155
 
100
156
  {#if account}
@@ -1,5 +1,6 @@
1
1
  import type { SiteLink } from '../../config/types.js';
2
- import type { EditDescriptor } from '../../edit/types.js';
2
+ import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
3
+ import type { SiteLinkEditMap } from './site-link-edit.js';
3
4
  interface Props {
4
5
  /**
5
6
  * Primary navigation entries. A prop rather than a package constant:
@@ -20,6 +21,10 @@ interface Props {
20
21
  * text instead of an anchor (see ActionLabel).
21
22
  */
22
23
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
24
+ /** Panel rows (href, order) per link — the label edits via `editFor`. */
25
+ propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
26
+ /** Names the links' collection; with `applyOp`, turns on add/remove. */
27
+ collection?: CollectionRef;
23
28
  }
24
29
  declare const Nav: import("svelte").Component<Props, {}, "">;
25
30
  type Nav = ReturnType<typeof Nav>;
@@ -0,0 +1,17 @@
1
+ import type { EntityOp, PropertyDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * What the CMS may edit on one nav/footer link beyond its LABEL (which edits
4
+ * through the existing `editFor` ActionLabel swap): the href and the display
5
+ * order are panel rows, and removal arrives injected by the list from its
6
+ * `collection` — the Timeline/CollaboratorList pattern.
7
+ */
8
+ export interface SiteLinkEditMap {
9
+ href?: PropertyDescriptor;
10
+ order?: PropertyDescriptor;
11
+ /** Accessible name for the frame, defaulting to the link's label. */
12
+ label?: string;
13
+ /** Set by the list from its `collection` — removal of this row. */
14
+ removeOp?: Extract<EntityOp, {
15
+ kind: 'remove';
16
+ }>;
17
+ }
@@ -0,0 +1 @@
1
+ export {};