@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
@@ -1,10 +1,19 @@
1
1
  <script module lang="ts">
2
- import type { EditDescriptor } from '../../edit/types.js';
2
+ import type { EditDescriptor, PropertyDescriptor } from '../../edit/types.js';
3
3
 
4
- /** Which of the card's localized fields are editable at this render site. */
4
+ /**
5
+ * Which of the card's fields are editable at this render site: localized
6
+ * text inline, the record-shaped scalars through the frame's panel.
7
+ */
5
8
  export interface ProjectEditMap {
6
9
  title?: EditDescriptor;
7
10
  excerpt?: EditDescriptor;
11
+ kind?: PropertyDescriptor;
12
+ publishedOn?: PropertyDescriptor;
13
+ externalUrl?: PropertyDescriptor;
14
+ image?: PropertyDescriptor;
15
+ /** Accessible name for the frame, e.g. the project's title. */
16
+ label?: string;
8
17
  }
9
18
  </script>
10
19
 
@@ -14,7 +23,10 @@
14
23
  import type { ProjectCardData } from '../../content/types.js';
15
24
  import { getEditAdapter } from '../../edit/context.js';
16
25
  import Editable from '../../edit/Editable.svelte';
26
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
27
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
17
28
  import CardMedia from '../ui/CardMedia.svelte';
29
+ import DraftBadge from '../../edit/chrome/DraftBadge.svelte';
18
30
  import CardTitle from '../ui/CardTitle.svelte';
19
31
  import DateText from '../ui/DateText.svelte';
20
32
  import Link from '../ui/Link.svelte';
@@ -35,41 +47,76 @@
35
47
  // See WeeklieCard: an editing title renders as plain text, never inside
36
48
  // the story link or the external anchor.
37
49
  const titleEditing = $derived(edit?.title !== undefined && (adapter?.isEditing ?? false));
50
+
51
+ /**
52
+ * Default kind options are the raw union members: no message keys exist
53
+ * for them, and a host wanting worded labels passes `options` in its
54
+ * descriptor, which wins over these (same spread rule as the milestone
55
+ * category select).
56
+ */
57
+ const KIND_OPTIONS = [
58
+ { value: 'collaboration', label: 'Collaboration' },
59
+ { value: 'passion', label: 'Passion' }
60
+ ];
61
+
62
+ const panelRows = $derived(
63
+ [
64
+ edit?.kind && {
65
+ descriptor: { options: KIND_OPTIONS, ...edit.kind },
66
+ value: project.kind as string
67
+ },
68
+ edit?.publishedOn && { descriptor: edit.publishedOn, value: project.publishedOn },
69
+ edit?.externalUrl && { descriptor: edit.externalUrl, value: project.externalUrl },
70
+ edit?.image && { descriptor: edit.image, value: project.imageUrl }
71
+ ].filter((row) => row !== undefined)
72
+ );
73
+ const frameSpec = $derived(
74
+ edit && panelRows.length > 0
75
+ ? { label: edit.label ?? project.title, hasPanel: true }
76
+ : undefined
77
+ );
38
78
  </script>
39
79
 
40
80
  <article class={variant}>
41
- <CardMedia src={project.imageUrl} alt="" ratio="16 / 9" width="1200" height="675" />
42
- <div class="text">
43
- <DateText value={project.publishedOn} />
44
- <CardTitle>
45
- {#if titleEditing}
46
- <Editable edit={edit?.title} value={project.title}>
47
- {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
48
- </Editable>
49
- {:else if project.hasStory}
50
- <Link href={`/what-we-do/${project.slug}`}>{project.title}</Link>
51
- {:else if project.externalUrl}
52
- <a href={project.externalUrl} rel="external noopener">{project.title}</a>
53
- {:else}
54
- {project.title}
81
+ <!-- Inside the article — see WeeklieCard. -->
82
+ <EditFrame spec={frameSpec}>
83
+ {#snippet panel()}
84
+ <EditPanel rows={panelRows} />
85
+ {/snippet}
86
+ <CardMedia src={project.imageUrl} alt="" ratio="16 / 9" width="1200" height="675" />
87
+ <div class="text">
88
+ {#if project.draft}<DraftBadge />{/if}
89
+ <DateText value={project.publishedOn} />
90
+ <CardTitle>
91
+ {#if titleEditing}
92
+ <Editable edit={edit?.title} value={project.title}>
93
+ {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
94
+ </Editable>
95
+ {:else if project.hasStory}
96
+ <Link href={`/what-we-do/${project.slug}`}>{project.title}</Link>
97
+ {:else if project.externalUrl}
98
+ <a href={project.externalUrl} rel="external noopener">{project.title}</a>
99
+ {:else}
100
+ {project.title}
101
+ {/if}
102
+ </CardTitle>
103
+ <Editable edit={edit?.excerpt} value={project.excerpt}>
104
+ {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
105
+ </Editable>
106
+ {#if project.hasStory}
107
+ <ActionLabel
108
+ edit={config.messageEdit?.('common_readMore')}
109
+ value={config.messages.common_readMore()}
110
+ >
111
+ {#snippet control()}
112
+ <Link href={`/what-we-do/${project.slug}`} class="more">
113
+ {config.messages.common_readMore()}
114
+ </Link>
115
+ {/snippet}
116
+ </ActionLabel>
55
117
  {/if}
56
- </CardTitle>
57
- <Editable edit={edit?.excerpt} value={project.excerpt}>
58
- {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
59
- </Editable>
60
- {#if project.hasStory}
61
- <ActionLabel
62
- edit={config.messageEdit?.('common_readMore')}
63
- value={config.messages.common_readMore()}
64
- >
65
- {#snippet control()}
66
- <Link href={`/what-we-do/${project.slug}`} class="more">
67
- {config.messages.common_readMore()}
68
- </Link>
69
- {/snippet}
70
- </ActionLabel>
71
- {/if}
72
- </div>
118
+ </div>
119
+ </EditFrame>
73
120
  </article>
74
121
 
75
122
  <style>
@@ -1,8 +1,17 @@
1
- import type { EditDescriptor } from '../../edit/types.js';
2
- /** Which of the card's localized fields are editable at this render site. */
1
+ import type { EditDescriptor, PropertyDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * Which of the card's fields are editable at this render site: localized
4
+ * text inline, the record-shaped scalars through the frame's panel.
5
+ */
3
6
  export interface ProjectEditMap {
4
7
  title?: EditDescriptor;
5
8
  excerpt?: EditDescriptor;
9
+ kind?: PropertyDescriptor;
10
+ publishedOn?: PropertyDescriptor;
11
+ externalUrl?: PropertyDescriptor;
12
+ image?: PropertyDescriptor;
13
+ /** Accessible name for the frame, e.g. the project's title. */
14
+ label?: string;
6
15
  }
7
16
  import type { ProjectCardData } from '../../content/types.js';
8
17
  interface Props {
@@ -1,25 +1,99 @@
1
+ <script module lang="ts">
2
+ import type { EntityOp, PropertyDescriptor } from '../../edit/types.js';
3
+
4
+ /**
5
+ * A collaborator is three plain (non-localized) columns, so everything
6
+ * edits through the frame's PANEL — the per-locale inline contract does
7
+ * not apply to any of it.
8
+ */
9
+ export interface CollaboratorEditMap {
10
+ personName?: PropertyDescriptor;
11
+ affiliation?: PropertyDescriptor;
12
+ url?: PropertyDescriptor;
13
+ /** Accessible name for the frame — usually the person's name. */
14
+ label?: string;
15
+ /** Set by the list from its `collection` — removal of this row. */
16
+ removeOp?: Extract<EntityOp, { kind: 'remove' }>;
17
+ }
18
+ </script>
19
+
1
20
  <script lang="ts">
2
21
  import type { CollaboratorData } from '../../content/types.js';
22
+ import AddSlot from '../../edit/chrome/AddSlot.svelte';
23
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
24
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
25
+ import { getEditAdapter } from '../../edit/context.js';
26
+ import type { CollectionRef } from '../../edit/types.js';
3
27
 
4
28
  interface Props {
5
29
  collaborators: CollaboratorData[];
30
+ /** Edit descriptors per row; returning undefined leaves it read-only. */
31
+ editFor?: (collaborator: CollaboratorData) => CollaboratorEditMap | undefined;
32
+ /**
33
+ * Names the collection these rows belong to. Set alongside an adapter
34
+ * with `applyOp`, it turns on the add slot and each frame's remove —
35
+ * for rows that carry an `id` (removal needs an identity).
36
+ */
37
+ collection?: CollectionRef;
6
38
  }
7
39
 
8
- let { collaborators }: Props = $props();
40
+ let { collaborators, editFor, collection }: Props = $props();
41
+
42
+ const adapter = getEditAdapter();
43
+
44
+ const structural = $derived(
45
+ collection !== undefined && (adapter?.isEditing ?? false) && adapter?.applyOp !== undefined
46
+ );
47
+
48
+ /** The map the host gave, plus removal — the list owns identity. */
49
+ function editMapFor(collaborator: CollaboratorData): CollaboratorEditMap | undefined {
50
+ const map = editFor?.(collaborator);
51
+ if (!structural || !collection || collaborator.id === undefined) return map;
52
+ return { ...map, removeOp: { kind: 'remove', collection, id: collaborator.id } };
53
+ }
54
+
55
+ function rowsFor(collaborator: CollaboratorData, map: CollaboratorEditMap | undefined) {
56
+ return [
57
+ map?.personName && { descriptor: map.personName, value: collaborator.personName },
58
+ map?.affiliation && { descriptor: map.affiliation, value: collaborator.affiliation },
59
+ map?.url && { descriptor: map.url, value: collaborator.url }
60
+ ].filter((row) => row !== undefined);
61
+ }
9
62
  </script>
10
63
 
11
64
  <ul>
12
- {#each collaborators as collaborator (collaborator.personName + collaborator.affiliation)}
65
+ {#each collaborators as collaborator (collaborator.id ?? collaborator.personName + collaborator.affiliation)}
66
+ {@const map = editMapFor(collaborator)}
67
+ {@const rows = rowsFor(collaborator, map)}
13
68
  <li>
14
- <strong>{collaborator.personName}</strong>
15
- ·
16
- {#if collaborator.url}
17
- <a href={collaborator.url} rel="external noopener">{collaborator.affiliation}</a>
18
- {:else}
19
- {collaborator.affiliation}
20
- {/if}
69
+ <!-- Inside the li, so the list's own layout never gains a child. -->
70
+ <EditFrame
71
+ spec={map && (rows.length > 0 || map.removeOp)
72
+ ? {
73
+ label: map.label ?? collaborator.personName,
74
+ hasPanel: rows.length > 0,
75
+ removeOp: map.removeOp
76
+ }
77
+ : undefined}
78
+ >
79
+ {#snippet panel()}
80
+ <EditPanel {rows} />
81
+ {/snippet}
82
+ <strong>{collaborator.personName}</strong>
83
+ ·
84
+ {#if collaborator.url}
85
+ <a href={collaborator.url} rel="external noopener">{collaborator.affiliation}</a>
86
+ {:else}
87
+ {collaborator.affiliation}
88
+ {/if}
89
+ </EditFrame>
21
90
  </li>
22
91
  {/each}
92
+ {#if structural && collection}
93
+ <li class="add-slot">
94
+ <AddSlot op={{ kind: 'create', collection }} />
95
+ </li>
96
+ {/if}
23
97
  </ul>
24
98
 
25
99
  <style>
@@ -34,4 +108,8 @@
34
108
  a {
35
109
  color: var(--color-brand);
36
110
  }
111
+
112
+ li.add-slot {
113
+ list-style: none;
114
+ }
37
115
  </style>
@@ -1,6 +1,32 @@
1
+ import type { EntityOp, PropertyDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * A collaborator is three plain (non-localized) columns, so everything
4
+ * edits through the frame's PANEL — the per-locale inline contract does
5
+ * not apply to any of it.
6
+ */
7
+ export interface CollaboratorEditMap {
8
+ personName?: PropertyDescriptor;
9
+ affiliation?: PropertyDescriptor;
10
+ url?: PropertyDescriptor;
11
+ /** Accessible name for the frame — usually the person's name. */
12
+ label?: string;
13
+ /** Set by the list from its `collection` — removal of this row. */
14
+ removeOp?: Extract<EntityOp, {
15
+ kind: 'remove';
16
+ }>;
17
+ }
1
18
  import type { CollaboratorData } from '../../content/types.js';
19
+ import type { CollectionRef } from '../../edit/types.js';
2
20
  interface Props {
3
21
  collaborators: CollaboratorData[];
22
+ /** Edit descriptors per row; returning undefined leaves it read-only. */
23
+ editFor?: (collaborator: CollaboratorData) => CollaboratorEditMap | undefined;
24
+ /**
25
+ * Names the collection these rows belong to. Set alongside an adapter
26
+ * with `applyOp`, it turns on the add slot and each frame's remove —
27
+ * for rows that carry an `id` (removal needs an identity).
28
+ */
29
+ collection?: CollectionRef;
4
30
  }
5
31
  declare const CollaboratorList: import("svelte").Component<Props, {}, "">;
6
32
  type CollaboratorList = ReturnType<typeof CollaboratorList>;
@@ -1,20 +1,26 @@
1
1
  <script module lang="ts">
2
- import type { EditDescriptor } from '../../edit/types.js';
2
+ import type { EditDescriptor, PropertyDescriptor } from '../../edit/types.js';
3
3
 
4
4
  /**
5
- * Which of the card's localized fields are editable at this render site.
6
- * `name` is deliberately absent: it is a plain-text column, not a
7
- * localized one, so the per-locale save contract does not apply to it.
5
+ * Which of the card's fields are editable at this render site. `name` and
6
+ * `photo` are plain (non-localized) columns, so they edit through the
7
+ * frame's PANEL — the per-locale inline contract does not apply to them.
8
8
  */
9
9
  export interface TeamMemberEditMap {
10
10
  role?: EditDescriptor;
11
11
  bio?: EditDescriptor;
12
+ name?: PropertyDescriptor;
13
+ photo?: PropertyDescriptor;
14
+ /** Accessible name for the frame — usually the member's name. */
15
+ label?: string;
12
16
  }
13
17
  </script>
14
18
 
15
19
  <script lang="ts">
16
20
  import type { TeamMemberData } from '../../content/types.js';
17
21
  import Editable from '../../edit/Editable.svelte';
22
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
23
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
18
24
  import CardMedia from '../ui/CardMedia.svelte';
19
25
 
20
26
  interface Props {
@@ -26,25 +32,41 @@
26
32
  }
27
33
 
28
34
  let { member, variant = 'board', edit }: Props = $props();
35
+
36
+ const panelRows = $derived(
37
+ [
38
+ edit?.name && { descriptor: edit.name, value: member.name },
39
+ edit?.photo && { descriptor: edit.photo, value: member.photoUrl }
40
+ ].filter((row) => row !== undefined)
41
+ );
42
+ const frameSpec = $derived(
43
+ edit && panelRows.length > 0 ? { label: edit.label ?? member.name, hasPanel: true } : undefined
44
+ );
29
45
  </script>
30
46
 
31
47
  <article class={variant}>
32
- <CardMedia
33
- src={member.photoUrl}
34
- alt={member.name}
35
- ratio={variant === 'featured' ? '4 / 5' : '1 / 1'}
36
- width="400"
37
- height="500"
38
- />
39
- <h3>{member.name}</h3>
40
- <Editable edit={edit?.role} value={member.role}>
41
- {#snippet children(text, attrs)}<p class="role" {...attrs}>{text}</p>{/snippet}
42
- </Editable>
43
- {#if member.bio}
44
- <Editable edit={edit?.bio} value={member.bio}>
45
- {#snippet children(text, attrs)}<p class="bio" {...attrs}>{text}</p>{/snippet}
48
+ <!-- Inside the article — see WeeklieCard. -->
49
+ <EditFrame spec={frameSpec}>
50
+ {#snippet panel()}
51
+ <EditPanel rows={panelRows} />
52
+ {/snippet}
53
+ <CardMedia
54
+ src={member.photoUrl}
55
+ alt={member.name}
56
+ ratio={variant === 'featured' ? '4 / 5' : '1 / 1'}
57
+ width="400"
58
+ height="500"
59
+ />
60
+ <h3>{member.name}</h3>
61
+ <Editable edit={edit?.role} value={member.role}>
62
+ {#snippet children(text, attrs)}<p class="role" {...attrs}>{text}</p>{/snippet}
46
63
  </Editable>
47
- {/if}
64
+ {#if member.bio}
65
+ <Editable edit={edit?.bio} value={member.bio}>
66
+ {#snippet children(text, attrs)}<p class="bio" {...attrs}>{text}</p>{/snippet}
67
+ </Editable>
68
+ {/if}
69
+ </EditFrame>
48
70
  </article>
49
71
 
50
72
  <style>
@@ -1,12 +1,16 @@
1
- import type { EditDescriptor } from '../../edit/types.js';
1
+ import type { EditDescriptor, PropertyDescriptor } from '../../edit/types.js';
2
2
  /**
3
- * Which of the card's localized fields are editable at this render site.
4
- * `name` is deliberately absent: it is a plain-text column, not a
5
- * localized one, so the per-locale save contract does not apply to it.
3
+ * Which of the card's fields are editable at this render site. `name` and
4
+ * `photo` are plain (non-localized) columns, so they edit through the
5
+ * frame's PANEL — the per-locale inline contract does not apply to them.
6
6
  */
7
7
  export interface TeamMemberEditMap {
8
8
  role?: EditDescriptor;
9
9
  bio?: EditDescriptor;
10
+ name?: PropertyDescriptor;
11
+ photo?: PropertyDescriptor;
12
+ /** Accessible name for the frame — usually the member's name. */
13
+ label?: string;
10
14
  }
11
15
  import type { TeamMemberData } from '../../content/types.js';
12
16
  interface Props {
@@ -29,6 +29,7 @@
29
29
  import Editable from '../../edit/Editable.svelte';
30
30
  import { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from '../../utils/milestones.js';
31
31
  import CardMedia from '../ui/CardMedia.svelte';
32
+ import DraftBadge from '../../edit/chrome/DraftBadge.svelte';
32
33
  import DateText from '../ui/DateText.svelte';
33
34
  import Link from '../ui/Link.svelte';
34
35
 
@@ -110,6 +111,7 @@
110
111
  {#snippet children(text, attrs)}<p class="category" {...attrs}>{text}</p>{/snippet}
111
112
  </Editable>
112
113
  <DateText value={milestone.occurredOn} />
114
+ {#if milestone.draft}<DraftBadge />{/if}
113
115
  <Editable edit={edit?.title} value={milestone.title}>
114
116
  {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
115
117
  </Editable>
@@ -119,7 +121,7 @@
119
121
  </Editable>
120
122
  {/if}
121
123
  {#if milestone.imageUrls.length > 0}
122
- <CardMedia src={milestone.imageUrls[0]} alt="" width="1200" height="675" />
124
+ <CardMedia src={milestone.imageUrls[0] ?? null} alt="" width="1200" height="675" />
123
125
  {/if}
124
126
  {#if milestone.linkUrl}
125
127
  <p class="more">
@@ -1,8 +1,12 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLImgAttributes } from 'svelte/elements';
3
3
 
4
- interface Props extends HTMLImgAttributes {
5
- src: string;
4
+ interface Props extends Omit<HTMLImgAttributes, 'src'> {
5
+ /**
6
+ * Null/undefined renders the placeholder directly — a row may simply
7
+ * have no image yet (the CMS creates rows before anyone uploads one).
8
+ */
9
+ src: string | null | undefined;
6
10
  /** Empty string for decorative images (the surrounding card carries the text). */
7
11
  alt: string;
8
12
  /** CSS aspect-ratio (e.g. '16 / 9'); omit to keep the image's natural height. */
@@ -10,9 +14,54 @@
10
14
  }
11
15
 
12
16
  let { src, alt, ratio, ...rest }: Props = $props();
17
+
18
+ /**
19
+ * The src that 404'd, so a broken path degrades to the placeholder instead
20
+ * of the browser's broken-image box. Keyed by value, not a boolean: a NEW
21
+ * src (an editor fixing the path) gets a fresh attempt.
22
+ */
23
+ let failedSrc = $state<string | null>(null);
24
+ const showImage = $derived(Boolean(src) && src !== failedSrc);
25
+
26
+ // The placeholder must hold the image's footprint: the ratio prop when
27
+ // given, else the intrinsic width/height the caller passed, else square.
28
+ const placeholderRatio = $derived(
29
+ ratio ?? (rest.width && rest.height ? `${rest.width} / ${rest.height}` : '1 / 1')
30
+ );
13
31
  </script>
14
32
 
15
- <img {src} {alt} loading="lazy" style:aspect-ratio={ratio} {...rest} />
33
+ {#if showImage}
34
+ <img
35
+ {src}
36
+ {alt}
37
+ loading="lazy"
38
+ style:aspect-ratio={ratio}
39
+ onerror={() => (failedSrc = src ?? null)}
40
+ {...rest}
41
+ />
42
+ {:else}
43
+ <!-- Same accessibility contract as the img: named when alt has text,
44
+ invisible to readers when the image was decorative. -->
45
+ <div
46
+ class="placeholder"
47
+ style:aspect-ratio={placeholderRatio}
48
+ role={alt ? 'img' : undefined}
49
+ aria-label={alt || undefined}
50
+ aria-hidden={alt ? undefined : true}
51
+ >
52
+ <svg
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ stroke-width="1.5"
57
+ aria-hidden="true"
58
+ >
59
+ <rect x="3" y="4" width="18" height="16" rx="2" />
60
+ <circle cx="9" cy="10" r="1.8" />
61
+ <path d="M3.5 17.5 9 13l4 3.2 4-4.2 3.5 4" />
62
+ </svg>
63
+ </div>
64
+ {/if}
16
65
 
17
66
  <style>
18
67
  img {
@@ -20,4 +69,18 @@
20
69
  background: var(--color-band-grey);
21
70
  object-fit: cover;
22
71
  }
72
+
73
+ .placeholder {
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ width: 100%;
78
+ border-radius: var(--radius);
79
+ background: var(--color-band-grey);
80
+ color: var(--color-ink-muted);
81
+ }
82
+
83
+ .placeholder svg {
84
+ width: clamp(1.5rem, 18%, 3rem);
85
+ }
23
86
  </style>
@@ -1,6 +1,10 @@
1
1
  import type { HTMLImgAttributes } from 'svelte/elements';
2
- interface Props extends HTMLImgAttributes {
3
- src: string;
2
+ interface Props extends Omit<HTMLImgAttributes, 'src'> {
3
+ /**
4
+ * Null/undefined renders the placeholder directly — a row may simply
5
+ * have no image yet (the CMS creates rows before anyone uploads one).
6
+ */
7
+ src: string | null | undefined;
4
8
  /** Empty string for decorative images (the surrounding card carries the text). */
5
9
  alt: string;
6
10
  /** CSS aspect-ratio (e.g. '16 / 9'); omit to keep the image's natural height. */
@@ -1,4 +1,8 @@
1
1
  <script lang="ts">
2
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
3
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
4
+ import type { PropertyDescriptor } from '../../edit/types.js';
5
+
2
6
  interface Props {
3
7
  /** Ties the label to the input; override when a page renders two. */
4
8
  id?: string;
@@ -8,6 +12,12 @@
8
12
  /** Called after the debounce delay with the trimmed query. */
9
13
  onsearch: (query: string) => void;
10
14
  debounceMs?: number;
15
+ /**
16
+ * A placeholder cannot hold a caret, so it edits through the frame's
17
+ * panel: one text row over whatever the host's descriptor names
18
+ * (a chrome key on the foundation site). Inert without an adapter.
19
+ */
20
+ placeholderEdit?: PropertyDescriptor;
11
21
  }
12
22
 
13
23
  const DEFAULT_DEBOUNCE_MS = 300;
@@ -18,9 +28,12 @@
18
28
  placeholder,
19
29
  label,
20
30
  onsearch,
21
- debounceMs = DEFAULT_DEBOUNCE_MS
31
+ debounceMs = DEFAULT_DEBOUNCE_MS,
32
+ placeholderEdit
22
33
  }: Props = $props();
23
34
 
35
+ const frameSpec = $derived(placeholderEdit !== undefined ? { label, hasPanel: true } : undefined);
36
+
24
37
  // svelte-ignore state_referenced_locally
25
38
  let query = $state(value);
26
39
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -70,15 +83,23 @@
70
83
  </script>
71
84
 
72
85
  <form role="search" onsubmit={handleSubmit}>
73
- <label class="visually-hidden" for={id}>{label}</label>
74
- <input
75
- {id}
76
- class="control"
77
- type="search"
78
- {placeholder}
79
- bind:value={query}
80
- oninput={handleInput}
81
- />
86
+ <!-- Inside the form, so the host's toolbar layout never gains a child. -->
87
+ <EditFrame spec={frameSpec}>
88
+ {#snippet panel()}
89
+ {#if placeholderEdit}
90
+ <EditPanel rows={[{ descriptor: placeholderEdit, value: placeholder }]} />
91
+ {/if}
92
+ {/snippet}
93
+ <label class="visually-hidden" for={id}>{label}</label>
94
+ <input
95
+ {id}
96
+ class="control"
97
+ type="search"
98
+ {placeholder}
99
+ bind:value={query}
100
+ oninput={handleInput}
101
+ />
102
+ </EditFrame>
82
103
  </form>
83
104
 
84
105
  <style>
@@ -1,3 +1,4 @@
1
+ import type { PropertyDescriptor } from '../../edit/types.js';
1
2
  interface Props {
2
3
  /** Ties the label to the input; override when a page renders two. */
3
4
  id?: string;
@@ -7,6 +8,12 @@ interface Props {
7
8
  /** Called after the debounce delay with the trimmed query. */
8
9
  onsearch: (query: string) => void;
9
10
  debounceMs?: number;
11
+ /**
12
+ * A placeholder cannot hold a caret, so it edits through the frame's
13
+ * panel: one text row over whatever the host's descriptor names
14
+ * (a chrome key on the foundation site). Inert without an adapter.
15
+ */
16
+ placeholderEdit?: PropertyDescriptor;
10
17
  }
11
18
  declare const SearchInput: import("svelte").Component<Props, {}, "">;
12
19
  type SearchInput = ReturnType<typeof SearchInput>;