@vit-foundation/ui 0.10.1 → 0.11.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.
@@ -2,6 +2,9 @@
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import type { MilestoneData } from '../../content/types.js';
4
4
  import type { MilestoneEditMap } from './TimelineMilestone.svelte';
5
+ import AddSlot from '../../edit/chrome/AddSlot.svelte';
6
+ import { getEditAdapter } from '../../edit/context.js';
7
+ import type { CollectionRef } from '../../edit/types.js';
5
8
  import { yearOf } from '../../utils/dates.js';
6
9
  import TimelineMilestone from './TimelineMilestone.svelte';
7
10
 
@@ -11,11 +14,30 @@
11
14
  variant?: 'compact' | 'full';
12
15
  /** Edit descriptors for one milestone's fields; see TimelineMilestone. */
13
16
  editFor?: (milestone: MilestoneData) => MilestoneEditMap | undefined;
17
+ /**
18
+ * Names the collection these milestones are rows of. Set alongside an
19
+ * adapter with `applyOp`, it turns on the add slots and each frame's
20
+ * remove. NO reorder: order derives from `occurredOn` — editing the
21
+ * date IS the reorder.
22
+ */
23
+ collection?: CollectionRef;
14
24
  }
15
25
 
16
- let { milestones, variant = 'compact', editFor }: Props = $props();
26
+ let { milestones, variant = 'compact', editFor, collection }: Props = $props();
17
27
 
18
28
  const config = getUiConfig();
29
+ const adapter = getEditAdapter();
30
+
31
+ const structural = $derived(
32
+ collection !== undefined && (adapter?.isEditing ?? false) && adapter?.applyOp !== undefined
33
+ );
34
+
35
+ /** The map the host gave, plus removal — the list owns identity and order. */
36
+ function editMapFor(milestone: MilestoneData): MilestoneEditMap | undefined {
37
+ const map = editFor?.(milestone);
38
+ if (!structural || !collection) return map;
39
+ return { ...map, removeOp: { kind: 'remove', collection, id: milestone.id } };
40
+ }
19
41
 
20
42
  interface TimelineEntry {
21
43
  milestone: MilestoneData;
@@ -46,13 +68,29 @@
46
68
  >
47
69
  <ol>
48
70
  {#each entries as entry (entry.milestone.id)}
71
+ {#if structural && collection}
72
+ <li class="add-slot">
73
+ <AddSlot
74
+ op={{
75
+ kind: 'create',
76
+ collection,
77
+ anchor: { id: entry.milestone.id, placement: 'before' }
78
+ }}
79
+ />
80
+ </li>
81
+ {/if}
49
82
  <li>
50
83
  {#if entry.yearMarker}
51
84
  <span class="year">{entry.yearMarker}</span>
52
85
  {/if}
53
- <TimelineMilestone milestone={entry.milestone} edit={editFor?.(entry.milestone)} />
86
+ <TimelineMilestone milestone={entry.milestone} edit={editMapFor(entry.milestone)} />
54
87
  </li>
55
88
  {/each}
89
+ {#if structural && collection}
90
+ <li class="add-slot">
91
+ <AddSlot op={{ kind: 'create', collection }} />
92
+ </li>
93
+ {/if}
56
94
  </ol>
57
95
  </div>
58
96
 
@@ -92,6 +130,13 @@
92
130
  flex-basis: 18rem;
93
131
  }
94
132
 
133
+ /* The add affordances sit between the cards without taking a card's slot. */
134
+ li.add-slot,
135
+ .full li.add-slot {
136
+ flex: 0 0 auto;
137
+ align-self: center;
138
+ }
139
+
95
140
  .year {
96
141
  position: absolute;
97
142
  top: -2.4rem;
@@ -1,11 +1,19 @@
1
1
  import type { MilestoneData } from '../../content/types.js';
2
2
  import type { MilestoneEditMap } from './TimelineMilestone.svelte';
3
+ import type { CollectionRef } from '../../edit/types.js';
3
4
  interface Props {
4
5
  milestones: MilestoneData[];
5
6
  /** 'compact' = homepage slice; 'full' = transparency history with year markers. */
6
7
  variant?: 'compact' | 'full';
7
8
  /** Edit descriptors for one milestone's fields; see TimelineMilestone. */
8
9
  editFor?: (milestone: MilestoneData) => MilestoneEditMap | undefined;
10
+ /**
11
+ * Names the collection these milestones are rows of. Set alongside an
12
+ * adapter with `applyOp`, it turns on the add slots and each frame's
13
+ * remove. NO reorder: order derives from `occurredOn` — editing the
14
+ * date IS the reorder.
15
+ */
16
+ collection?: CollectionRef;
9
17
  }
10
18
  declare const Timeline: import("svelte").Component<Props, {}, "">;
11
19
  type Timeline = ReturnType<typeof Timeline>;
@@ -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 the milestone's localized fields are editable at this render site. */
4
+ /**
5
+ * Which of the milestone's fields are editable at this render site:
6
+ * localized text inline (EditDescriptor), scalars through the frame's
7
+ * property panel (PropertyDescriptor), and removal from the collection.
8
+ */
5
9
  export interface MilestoneEditMap {
6
10
  title?: EditDescriptor;
7
11
  body?: EditDescriptor;
12
+ occurredOn?: PropertyDescriptor;
13
+ category?: PropertyDescriptor;
14
+ linkUrl?: PropertyDescriptor;
15
+ image?: PropertyDescriptor;
16
+ /** Accessible name for the frame, e.g. "Fita: Neix la fundació". */
17
+ label?: string;
18
+ /** Set by Timeline from its `collection` — removal of this milestone. */
19
+ removeOp?: Extract<EntityOp, { kind: 'remove' }>;
8
20
  }
9
21
  </script>
10
22
 
11
23
  <script lang="ts">
12
24
  import { getUiConfig } from '../../config/context.js';
13
25
  import ActionLabel from '../../edit/ActionLabel.svelte';
14
- import type { MilestoneData } from '../../content/types.js';
26
+ import type { MilestoneCategory, MilestoneData } from '../../content/types.js';
27
+ import EditFrame from '../../edit/chrome/EditFrame.svelte';
28
+ import EditPanel from '../../edit/chrome/EditPanel.svelte';
15
29
  import Editable from '../../edit/Editable.svelte';
16
30
  import { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from '../../utils/milestones.js';
17
31
  import CardMedia from '../ui/CardMedia.svelte';
@@ -28,6 +42,41 @@
28
42
 
29
43
  const config = getUiConfig();
30
44
 
45
+ const CATEGORIES = Object.keys(MILESTONE_CATEGORY_COLOR) as MilestoneCategory[];
46
+
47
+ /**
48
+ * The panel rows, zipped from the map's descriptors and this milestone's
49
+ * values. The category select's options come from the same labels the chip
50
+ * renders — filled here so the host never re-derives them.
51
+ */
52
+ const panelRows = $derived(
53
+ [
54
+ edit?.occurredOn && { descriptor: edit.occurredOn, value: milestone.occurredOn },
55
+ edit?.category && {
56
+ descriptor: {
57
+ options: CATEGORIES.map((category) => ({
58
+ value: category,
59
+ label: milestoneCategoryLabel(category, config.messages)
60
+ })),
61
+ ...edit.category
62
+ },
63
+ value: milestone.category
64
+ },
65
+ edit?.linkUrl && { descriptor: edit.linkUrl, value: milestone.linkUrl },
66
+ edit?.image && { descriptor: edit.image, value: milestone.imageUrls[0] ?? null }
67
+ ].filter((row) => row !== undefined)
68
+ );
69
+
70
+ const frameSpec = $derived(
71
+ edit && (panelRows.length > 0 || edit.removeOp)
72
+ ? {
73
+ label: edit.label ?? milestone.title,
74
+ hasPanel: panelRows.length > 0,
75
+ removeOp: edit.removeOp
76
+ }
77
+ : undefined
78
+ );
79
+
31
80
  const color = $derived(MILESTONE_CATEGORY_COLOR[milestone.category]);
32
81
  /**
33
82
  * `link_url` is an unconstrained editable column, and the `press` category
@@ -48,42 +97,49 @@
48
97
 
49
98
  <article style={`--milestone-color: ${color}`}>
50
99
  <span class="dot" aria-hidden="true"></span>
51
- <Editable
52
- edit={config.messageEdit?.(`category_${milestone.category}`)}
53
- value={milestoneCategoryLabel(milestone.category, config.messages)}
54
- >
55
- {#snippet children(text, attrs)}<p class="category" {...attrs}>{text}</p>{/snippet}
56
- </Editable>
57
- <DateText value={milestone.occurredOn} />
58
- <Editable edit={edit?.title} value={milestone.title}>
59
- {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
60
- </Editable>
61
- {#if milestone.body}
62
- <Editable edit={edit?.body} value={milestone.body}>
63
- {#snippet children(text, attrs)}<p class="body" {...attrs}>{text}</p>{/snippet}
100
+ <!-- The frame sits INSIDE the article so the timeline's flex row never
101
+ gains an unexpected child; inactive it renders the content alone. -->
102
+ <EditFrame spec={frameSpec}>
103
+ {#snippet panel()}
104
+ <EditPanel rows={panelRows} />
105
+ {/snippet}
106
+ <Editable
107
+ edit={config.messageEdit?.(`category_${milestone.category}`)}
108
+ value={milestoneCategoryLabel(milestone.category, config.messages)}
109
+ >
110
+ {#snippet children(text, attrs)}<p class="category" {...attrs}>{text}</p>{/snippet}
111
+ </Editable>
112
+ <DateText value={milestone.occurredOn} />
113
+ <Editable edit={edit?.title} value={milestone.title}>
114
+ {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
64
115
  </Editable>
65
- {/if}
66
- {#if milestone.imageUrls.length > 0}
67
- <CardMedia src={milestone.imageUrls[0]} alt="" width="1200" height="675" />
68
- {/if}
69
- {#if milestone.linkUrl}
70
- <p class="more">
71
- <ActionLabel
72
- edit={config.messageEdit?.('common_readMore')}
73
- value={config.messages.common_readMore()}
74
- >
75
- {#snippet control()}
76
- {#if isExternal}
77
- <a href={milestone.linkUrl} rel="external noopener"
78
- >{config.messages.common_readMore()}</a
79
- >
80
- {:else if milestone.linkUrl}
81
- <Link href={milestone.linkUrl}>{config.messages.common_readMore()}</Link>
82
- {/if}
83
- {/snippet}
84
- </ActionLabel>
85
- </p>
86
- {/if}
116
+ {#if milestone.body}
117
+ <Editable edit={edit?.body} value={milestone.body}>
118
+ {#snippet children(text, attrs)}<p class="body" {...attrs}>{text}</p>{/snippet}
119
+ </Editable>
120
+ {/if}
121
+ {#if milestone.imageUrls.length > 0}
122
+ <CardMedia src={milestone.imageUrls[0]} alt="" width="1200" height="675" />
123
+ {/if}
124
+ {#if milestone.linkUrl}
125
+ <p class="more">
126
+ <ActionLabel
127
+ edit={config.messageEdit?.('common_readMore')}
128
+ value={config.messages.common_readMore()}
129
+ >
130
+ {#snippet control()}
131
+ {#if isExternal}
132
+ <a href={milestone.linkUrl} rel="external noopener"
133
+ >{config.messages.common_readMore()}</a
134
+ >
135
+ {:else if milestone.linkUrl}
136
+ <Link href={milestone.linkUrl}>{config.messages.common_readMore()}</Link>
137
+ {/if}
138
+ {/snippet}
139
+ </ActionLabel>
140
+ </p>
141
+ {/if}
142
+ </EditFrame>
87
143
  </article>
88
144
 
89
145
  <style>
@@ -1,8 +1,22 @@
1
- import type { EditDescriptor } from '../../edit/types.js';
2
- /** Which of the milestone's localized fields are editable at this render site. */
1
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * Which of the milestone's fields are editable at this render site:
4
+ * localized text inline (EditDescriptor), scalars through the frame's
5
+ * property panel (PropertyDescriptor), and removal from the collection.
6
+ */
3
7
  export interface MilestoneEditMap {
4
8
  title?: EditDescriptor;
5
9
  body?: EditDescriptor;
10
+ occurredOn?: PropertyDescriptor;
11
+ category?: PropertyDescriptor;
12
+ linkUrl?: PropertyDescriptor;
13
+ image?: PropertyDescriptor;
14
+ /** Accessible name for the frame, e.g. "Fita: Neix la fundació". */
15
+ label?: string;
16
+ /** Set by Timeline from its `collection` — removal of this milestone. */
17
+ removeOp?: Extract<EntityOp, {
18
+ kind: 'remove';
19
+ }>;
6
20
  }
7
21
  import type { MilestoneData } from '../../content/types.js';
8
22
  interface Props {
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { getUiConfig } from '../../config/context.js';
2
3
  import { renderBody } from '../../content/richtext.js';
3
4
  import { getEditAdapter } from '../../edit/context.js';
4
5
  import type { EditDescriptor } from '../../edit/types.js';
@@ -23,6 +24,7 @@
23
24
  let { body, edit }: Props = $props();
24
25
 
25
26
  const adapter = getEditAdapter();
27
+ const config = getUiConfig();
26
28
  const canEdit = $derived(edit !== undefined && (adapter?.isEditing ?? false));
27
29
 
28
30
  /** Last persisted body: what renders, and what Cancel·la returns to. */
@@ -79,16 +81,18 @@
79
81
  class="control"
80
82
  bind:value={draft}
81
83
  rows={Math.max(6, draft.split('\n').length + 1)}
82
- aria-label={edit?.label ?? 'Edita el contingut'}
84
+ aria-label={edit?.label ?? config.editMessages.edit_editBody()}
83
85
  ></textarea>
84
86
  <div class="actions">
85
- <Button pending={status === 'saving' ? 1 : 0} onclick={() => void save()}>Desa</Button>
86
- <GhostButton onclick={cancel}>Cancel·la</GhostButton>
87
+ <Button pending={status === 'saving' ? 1 : 0} onclick={() => void save()}>
88
+ {config.editMessages.edit_save()}
89
+ </Button>
90
+ <GhostButton onclick={cancel}>{config.editMessages.edit_cancel()}</GhostButton>
87
91
  {#if status === 'error'}
88
- <p class="error" role="alert">Error en desar. Torna-ho a provar.</p>
92
+ <p class="error" role="alert">{config.editMessages.edit_saveError()}. Torna-ho a provar.</p>
89
93
  {/if}
90
94
  </div>
91
- <div class="richtext preview" aria-label="Vista prèvia">
95
+ <div class="richtext preview" aria-label={config.editMessages.edit_preview()}>
92
96
  {#each preview as block, index (index)}
93
97
  {#if block.type === 'h2'}
94
98
  <h2>{block.text}</h2>
@@ -108,7 +112,9 @@
108
112
  {/if}
109
113
  {/each}
110
114
  {#if canEdit}
111
- <p class="edit-row"><GhostButton onclick={open}>Edita</GhostButton></p>
115
+ <p class="edit-row">
116
+ <GhostButton onclick={open}>{config.editMessages.edit_edit()}</GhostButton>
117
+ </p>
112
118
  {/if}
113
119
  </div>
114
120
  {/if}
@@ -1,4 +1,5 @@
1
1
  import { getContext, setContext } from 'svelte';
2
+ import { defaultEditMessages } from './edit-messages.js';
2
3
  import { defaultMessages } from './messages.js';
3
4
  import { BASE_LOCALE, LOCALES } from './types.js';
4
5
  const KEY = Symbol.for('@vit-foundation/ui:config');
@@ -14,7 +15,8 @@ export const DEFAULT_UI_CONFIG = {
14
15
  url: () => undefined,
15
16
  canonicalPathname: (url) => url.pathname,
16
17
  siteName: 'ViT',
17
- messages: defaultMessages
18
+ messages: defaultMessages,
19
+ editMessages: defaultEditMessages
18
20
  };
19
21
  /**
20
22
  * Installs the app's config for the subtree. Takes a *thunk* so a provider
@@ -38,6 +40,9 @@ export function setUiConfig(input) {
38
40
  get messages() {
39
41
  return input().messages ?? defaultMessages;
40
42
  },
43
+ get editMessages() {
44
+ return input().editMessages ?? defaultEditMessages;
45
+ },
41
46
  // Undefined means inert: components render their message strings as
42
47
  // plain text, which is every app but the CMS.
43
48
  messageEdit: (key) => input().messageEdit?.(key)
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The editor chrome's own strings — status announcements, panel labels, the
3
+ * confirm-delete wording. A separate object from `UiMessages` on purpose:
4
+ * that interface mirrors the website's wording catalog (its keys are
5
+ * `ui_messages` rows a CMS edits), while these strings belong to the EDITOR
6
+ * a CMS host renders — they must never leak into the site's catalog, and
7
+ * they must never themselves become editable chrome.
8
+ *
9
+ * Same message-function style as `UiMessages`, and the same all-or-nothing
10
+ * override: a host replaces the whole object or keeps the Catalan defaults.
11
+ */
12
+ export interface EditMessages {
13
+ /** Inline + panel status announcements. */
14
+ edit_saving(): string;
15
+ edit_saved(): string;
16
+ edit_saveError(): string;
17
+ /** RichText's source editor. */
18
+ edit_save(): string;
19
+ edit_cancel(): string;
20
+ edit_edit(): string;
21
+ edit_preview(): string;
22
+ edit_editBody(): string;
23
+ /** The frame toolbar and its panel. */
24
+ edit_properties(params: {
25
+ label: string;
26
+ }): string;
27
+ edit_remove(): string;
28
+ edit_removeConfirm(params: {
29
+ label: string;
30
+ }): string;
31
+ edit_close(): string;
32
+ /** Collections. */
33
+ edit_add(params: {
34
+ label: string;
35
+ }): string;
36
+ edit_addFailed(): string;
37
+ /** Property rows. */
38
+ edit_uploadImage(): string;
39
+ edit_clearValue(): string;
40
+ edit_emptyRequired(): string;
41
+ }
42
+ /**
43
+ * Catalan defaults. The first three and the RichText strings reproduce the
44
+ * previously hardcoded copy byte-identically — swapping the source of a
45
+ * string must not repaint a single test or page.
46
+ */
47
+ export declare const defaultEditMessages: EditMessages;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Catalan defaults. The first three and the RichText strings reproduce the
3
+ * previously hardcoded copy byte-identically — swapping the source of a
4
+ * string must not repaint a single test or page.
5
+ */
6
+ export const defaultEditMessages = {
7
+ edit_saving: () => 'Desant…',
8
+ edit_saved: () => 'Desat',
9
+ edit_saveError: () => 'Error en desar',
10
+ edit_save: () => 'Desa',
11
+ edit_cancel: () => 'Cancel·la',
12
+ edit_edit: () => 'Edita',
13
+ edit_preview: () => 'Vista prèvia',
14
+ edit_editBody: () => 'Edita el contingut',
15
+ edit_properties: ({ label }) => `Propietats: ${label}`,
16
+ edit_remove: () => 'Elimina',
17
+ edit_removeConfirm: ({ label }) => `Segur que vols eliminar «${label}»? Es pot desfer des de l’historial.`,
18
+ edit_close: () => 'Tanca',
19
+ edit_add: ({ label }) => `Afegeix ${label}`,
20
+ edit_addFailed: () => 'No s’ha pogut afegir',
21
+ edit_uploadImage: () => 'Puja una imatge',
22
+ edit_clearValue: () => 'Buida el valor',
23
+ edit_emptyRequired: () => 'Aquest camp no pot quedar buit'
24
+ };
@@ -1,4 +1,6 @@
1
1
  export { DEFAULT_UI_CONFIG, getUiConfig, setUiConfig } from './context.js';
2
+ export { defaultEditMessages } from './edit-messages.js';
3
+ export type { EditMessages } from './edit-messages.js';
2
4
  export { defaultMessages } from './messages.js';
3
5
  export { default as UiProvider } from './UiProvider.svelte';
4
6
  export { BASE_LOCALE, LOCALES } from './types.js';
@@ -1,4 +1,5 @@
1
1
  export { DEFAULT_UI_CONFIG, getUiConfig, setUiConfig } from './context.js';
2
+ export { defaultEditMessages } from './edit-messages.js';
2
3
  export { defaultMessages } from './messages.js';
3
4
  export { default as UiProvider } from './UiProvider.svelte';
4
5
  export { BASE_LOCALE, LOCALES } from './types.js';
@@ -1,4 +1,5 @@
1
1
  import type { EditDescriptor } from '../edit/types.js';
2
+ import type { EditMessages } from './edit-messages.js';
2
3
  /**
3
4
  * The site's locales and their canonical member. Fixed rather than generic:
4
5
  * `ca` is load-bearing — it is the required key of every localized column
@@ -157,6 +158,12 @@ export interface UiConfig {
157
158
  /** Appended to document titles and shown as the wordmark. */
158
159
  siteName: string;
159
160
  messages: UiMessages;
161
+ /**
162
+ * The editor chrome's own strings (status announcements, panel wording) —
163
+ * resolved, Catalan by default. NOT part of `messages`: see
164
+ * `./edit-messages.js` for why the two catalogs must not mix.
165
+ */
166
+ editMessages: EditMessages;
160
167
  /**
161
168
  * Edit descriptor for one of THIS config's message strings, by catalog
162
169
  * key — the interface-wording half of edit mode. Optional and undefined by
@@ -175,5 +182,6 @@ export interface UiConfigInput {
175
182
  canonicalPathname?: UiConfig['canonicalPathname'];
176
183
  siteName?: string;
177
184
  messages?: UiMessages;
185
+ editMessages?: EditMessages;
178
186
  messageEdit?: UiConfig['messageEdit'];
179
187
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { getUiConfig } from '../config/context.js';
3
4
  import { getEditAdapter } from './context.js';
4
5
  import type { EditDescriptor } from './types.js';
5
6
 
@@ -55,6 +56,7 @@
55
56
  let { edit, value, children }: Props = $props();
56
57
 
57
58
  const adapter = getEditAdapter();
59
+ const config = getUiConfig();
58
60
 
59
61
  let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
60
62
  /** Last persisted value: what Escape restores and blur diffs against. */
@@ -121,16 +123,16 @@
121
123
  return;
122
124
  }
123
125
  status = 'saving';
124
- announcement = 'Desant…';
126
+ announcement = config.editMessages.edit_saving();
125
127
  try {
126
128
  await adapter.save(edit, draft);
127
129
  savedValue = draft;
128
130
  status = 'idle';
129
- announcement = 'Desat';
131
+ announcement = config.editMessages.edit_saved();
130
132
  } catch {
131
133
  // Draft stays in the DOM; the reader decides whether to retry.
132
134
  status = 'error';
133
- announcement = 'Error en desar';
135
+ announcement = config.editMessages.edit_saveError();
134
136
  }
135
137
  }
136
138
 
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ import Icon from '../../components/ui/Icon.svelte';
3
+ import { getUiConfig } from '../../config/context.js';
4
+ import { getEditAdapter } from '../context.js';
5
+ import type { EntityOp } from '../types.js';
6
+
7
+ /**
8
+ * The "+" affordance of an editable collection. Renders ONLY while the
9
+ * adapter is editing AND implements `applyOp` — otherwise nothing, the
10
+ * byte-identical invariant. The op resolves server-side and the host's
11
+ * refresh brings the new row in; this control only shows pending/error.
12
+ */
13
+ interface Props {
14
+ op: Extract<EntityOp, { kind: 'create' }>;
15
+ /** The entity noun for the label, e.g. "una fita". */
16
+ label?: string;
17
+ }
18
+
19
+ let { op, label = '' }: Props = $props();
20
+
21
+ const adapter = getEditAdapter();
22
+ const config = getUiConfig();
23
+
24
+ let pending = $state(false);
25
+ let announcement = $state('');
26
+
27
+ const active = $derived((adapter?.isEditing ?? false) && adapter?.applyOp !== undefined);
28
+ const text = $derived(config.editMessages.edit_add({ label }).trim());
29
+
30
+ async function add(): Promise<void> {
31
+ if (!adapter?.applyOp || pending) return;
32
+ pending = true;
33
+ announcement = '';
34
+ try {
35
+ await adapter.applyOp(op);
36
+ } catch {
37
+ announcement = config.editMessages.edit_addFailed();
38
+ } finally {
39
+ pending = false;
40
+ }
41
+ }
42
+ </script>
43
+
44
+ {#if active}
45
+ <button type="button" class="add" disabled={pending} onclick={() => void add()}>
46
+ <Icon name="plus" size={16} />
47
+ {text}
48
+ </button>
49
+ <span class="status" role="status">{announcement}</span>
50
+ {/if}
51
+
52
+ <style>
53
+ .add {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ gap: var(--space-1);
57
+ padding: var(--space-1) var(--space-3);
58
+ border: 1px dashed var(--color-ink-muted);
59
+ border-radius: 999px;
60
+ background: transparent;
61
+ color: var(--color-ink);
62
+ font: inherit;
63
+ font-size: var(--text-sm);
64
+ cursor: pointer;
65
+ transition: border-color var(--transition-fast);
66
+ }
67
+
68
+ .add:hover:not(:disabled) {
69
+ border-color: var(--color-brand);
70
+ color: var(--color-brand);
71
+ }
72
+
73
+ .add:disabled {
74
+ opacity: 0.5;
75
+ cursor: default;
76
+ }
77
+
78
+ .status {
79
+ position: absolute;
80
+ width: 1px;
81
+ height: 1px;
82
+ padding: 0;
83
+ margin: -1px;
84
+ overflow: hidden;
85
+ clip-path: inset(50%);
86
+ white-space: nowrap;
87
+ border: 0;
88
+ }
89
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { EntityOp } from '../types.js';
2
+ /**
3
+ * The "+" affordance of an editable collection. Renders ONLY while the
4
+ * adapter is editing AND implements `applyOp` — otherwise nothing, the
5
+ * byte-identical invariant. The op resolves server-side and the host's
6
+ * refresh brings the new row in; this control only shows pending/error.
7
+ */
8
+ interface Props {
9
+ op: Extract<EntityOp, {
10
+ kind: 'create';
11
+ }>;
12
+ /** The entity noun for the label, e.g. "una fita". */
13
+ label?: string;
14
+ }
15
+ declare const AddSlot: import("svelte").Component<Props, {}, "">;
16
+ type AddSlot = ReturnType<typeof AddSlot>;
17
+ export default AddSlot;