@vit-foundation/ui 0.13.1 → 0.15.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,8 @@
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import ActionLabel from '../../edit/ActionLabel.svelte';
4
4
  import Editable from '../../edit/Editable.svelte';
5
+ import LinkEdit from '../../edit/chrome/LinkEdit.svelte';
6
+ import { chromeProperty } from '../../edit/helpers.js';
5
7
  import { withNewsletterIntent } from '../../forms/transport.js';
6
8
  import type { NewsletterToggleFormInstance } from './AccountPanel.svelte';
7
9
  import Button from '../ui/Button.svelte';
@@ -25,6 +27,19 @@
25
27
  // verified — no app mail needed). Logged out, the band routes to the auth
26
28
  // pages carrying the intent as ?newsletter=1 (never an email in a URL).
27
29
  const f = $derived(newsletterToggleForm);
30
+
31
+ // The destinations are OPTIONAL catalog keys added after these components
32
+ // first shipped; a host without them keeps the built-in paths, and the
33
+ // intent query rides on whatever the key resolves to.
34
+ const signupHref = $derived(msg.comments_signupLinkHref?.() ?? '/signup');
35
+ const loginHref = $derived(msg.comments_loginLinkHref?.() ?? '/login');
36
+ // Gated like the form wording: descriptors exist only where the host
37
+ // exposes messageEdit; LinkEdit further requires saveProperty for the
38
+ // Adreça field.
39
+ const hrefProperty = (key: string) =>
40
+ config.messageEdit
41
+ ? chromeProperty(key, { type: 'text', label: config.editMessages.edit_linkUrl() })
42
+ : undefined;
28
43
  </script>
29
44
 
30
45
  <section class="newsletter" aria-labelledby="newsletter-title">
@@ -44,23 +59,29 @@
44
59
  >
45
60
  {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
46
61
  </Editable>
47
- <ActionLabel
48
- edit={config.messageEdit?.('comments_signupLink')}
49
- value={msg.comments_signupLink()}
62
+ <LinkEdit
63
+ text={{
64
+ edit: config.messageEdit?.('comments_signupLink'),
65
+ value: msg.comments_signupLink()
66
+ }}
67
+ href={{ descriptor: hrefProperty('comments_signupLinkHref'), value: signupHref }}
50
68
  >
51
- {#snippet control()}<Link href={withNewsletterIntent('/signup')}
69
+ {#snippet control()}<Link href={withNewsletterIntent(signupHref)}
52
70
  >{msg.comments_signupLink()}</Link
53
71
  >{/snippet}
54
- </ActionLabel>
72
+ </LinkEdit>
55
73
  ·
56
- <ActionLabel
57
- edit={config.messageEdit?.('comments_loginLink')}
58
- value={msg.comments_loginLink()}
74
+ <LinkEdit
75
+ text={{
76
+ edit: config.messageEdit?.('comments_loginLink'),
77
+ value: msg.comments_loginLink()
78
+ }}
79
+ href={{ descriptor: hrefProperty('comments_loginLinkHref'), value: loginHref }}
59
80
  >
60
- {#snippet control()}<Link href={withNewsletterIntent('/login')}
81
+ {#snippet control()}<Link href={withNewsletterIntent(loginHref)}
61
82
  >{msg.comments_loginLink()}</Link
62
83
  >{/snippet}
63
- </ActionLabel>
84
+ </LinkEdit>
64
85
  </p>
65
86
  {:else if account.newsletterSubscribed}
66
87
  <p class="prompt">
@@ -1,10 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import type { SiteLink } from '../../config/types.js';
4
- import ActionLabel from '../../edit/ActionLabel.svelte';
5
4
  import AddSlot from '../../edit/chrome/AddSlot.svelte';
6
- import EditFrame from '../../edit/chrome/EditFrame.svelte';
7
- import EditPanel from '../../edit/chrome/EditPanel.svelte';
5
+ import LinkEdit from '../../edit/chrome/LinkEdit.svelte';
8
6
  import { getEditAdapter } from '../../edit/context.js';
9
7
  import Editable from '../../edit/Editable.svelte';
10
8
  import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
@@ -20,7 +18,7 @@
20
18
  links: SiteLink[];
21
19
  /** Edit descriptors for the link labels — see Nav. */
22
20
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
23
- /** Panel rows (href, order) per link — see Nav. */
21
+ /** The modal's Adreça/Ordre rows and removal per link — see LinkEdit. */
24
22
  propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
25
23
  /** Names the links' collection; with `applyOp`, turns on add/remove. */
26
24
  collection?: CollectionRef;
@@ -42,13 +40,6 @@
42
40
  return { ...map, removeOp: { kind: 'remove', collection, id: link.id } };
43
41
  }
44
42
 
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
- }
51
-
52
43
  const year = new Date().getFullYear();
53
44
  </script>
54
45
 
@@ -60,25 +51,19 @@
60
51
  <ul>
61
52
  {#each links as link (link.href)}
62
53
  {@const map = editMapFor(link)}
63
- {@const rows = rowsFor(link, map)}
64
54
  <li>
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
- }
55
+ <!-- One gesture: the modal edits text, destination, order, removal. -->
56
+ <LinkEdit
57
+ text={{ edit: editFor?.(link), value: link.label }}
58
+ href={{ descriptor: map?.href, value: link.href }}
59
+ extras={map?.order
60
+ ? [{ descriptor: map.order, value: String(link.order ?? 0) }]
73
61
  : undefined}
62
+ removeOp={map?.removeOp}
63
+ label={map?.label ?? link.label}
74
64
  >
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>
65
+ {#snippet control()}<Link href={link.href}>{link.label}</Link>{/snippet}
66
+ </LinkEdit>
82
67
  </li>
83
68
  {/each}
84
69
  {#if structural && collection}
@@ -10,7 +10,7 @@ interface Props {
10
10
  links: SiteLink[];
11
11
  /** Edit descriptors for the link labels — see Nav. */
12
12
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
13
- /** Panel rows (href, order) per link — see Nav. */
13
+ /** The modal's Adreça/Ordre rows and removal per link — see LinkEdit. */
14
14
  propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
15
15
  /** Names the links' collection; with `applyOp`, turns on add/remove. */
16
16
  collection?: CollectionRef;
@@ -1,10 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { getUiConfig } from '../../config/context.js';
3
3
  import type { SiteLink } from '../../config/types.js';
4
- import ActionLabel from '../../edit/ActionLabel.svelte';
5
4
  import AddSlot from '../../edit/chrome/AddSlot.svelte';
6
- import EditFrame from '../../edit/chrome/EditFrame.svelte';
7
- import EditPanel from '../../edit/chrome/EditPanel.svelte';
5
+ import LinkEdit from '../../edit/chrome/LinkEdit.svelte';
8
6
  import { getEditAdapter } from '../../edit/context.js';
9
7
  import type { CollectionRef, EditDescriptor } from '../../edit/types.js';
10
8
  import { isPathUnder } from '../../utils/paths.js';
@@ -26,11 +24,12 @@
26
24
  /**
27
25
  * Edit descriptors for the link LABELS — a function because the host
28
26
  * owns both the routes and the message keys behind them. While the
29
- * adapter is editing, a link with a descriptor renders as editable
30
- * text instead of an anchor (see ActionLabel).
27
+ * adapter is editing, a link with a descriptor swaps for a button that
28
+ * opens the LINK MODAL (see LinkEdit) — every half of the entry edits
29
+ * in that one gesture.
31
30
  */
32
31
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
33
- /** Panel rows (href, order) per link — the label edits via `editFor`. */
32
+ /** The modal's Adreça/Ordre rows and removal per link — see LinkEdit. */
34
33
  propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
35
34
  /** Names the links' collection; with `applyOp`, turns on add/remove. */
36
35
  collection?: CollectionRef;
@@ -59,12 +58,6 @@
59
58
  return { ...map, removeOp: { kind: 'remove', collection, id: link.id } };
60
59
  }
61
60
 
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
- }
68
61
  const msg = $derived(config.messages);
69
62
 
70
63
  let isMenuOpen = $state(false);
@@ -121,29 +114,24 @@
121
114
  <ul class="links">
122
115
  {#each links as link (link.href)}
123
116
  {@const map = editMapFor(link)}
124
- {@const rows = rowsFor(link, map)}
125
117
  <li>
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
- }
118
+ <!-- One gesture: clicking the entry opens the modal that edits
119
+ its text, destination, order and removal together. -->
120
+ <LinkEdit
121
+ text={{ edit: editFor?.(link), value: link.label }}
122
+ href={{ descriptor: map?.href, value: link.href }}
123
+ extras={map?.order
124
+ ? [{ descriptor: map.order, value: String(link.order ?? 0) }]
134
125
  : undefined}
126
+ removeOp={map?.removeOp}
127
+ label={map?.label ?? link.label}
135
128
  >
136
- {#snippet panel()}
137
- <EditPanel {rows} />
129
+ {#snippet control()}
130
+ <Link href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
131
+ {link.label}
132
+ </Link>
138
133
  {/snippet}
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>
134
+ </LinkEdit>
147
135
  </li>
148
136
  {/each}
149
137
  {#if structural && collection}
@@ -17,11 +17,12 @@ interface Props {
17
17
  /**
18
18
  * Edit descriptors for the link LABELS — a function because the host
19
19
  * owns both the routes and the message keys behind them. While the
20
- * adapter is editing, a link with a descriptor renders as editable
21
- * text instead of an anchor (see ActionLabel).
20
+ * adapter is editing, a link with a descriptor swaps for a button that
21
+ * opens the LINK MODAL (see LinkEdit) — every half of the entry edits
22
+ * in that one gesture.
22
23
  */
23
24
  editFor?: (link: SiteLink) => EditDescriptor | undefined;
24
- /** Panel rows (href, order) per link — the label edits via `editFor`. */
25
+ /** The modal's Adreça/Ordre rows and removal per link — see LinkEdit. */
25
26
  propertiesFor?: (link: SiteLink) => SiteLinkEditMap | undefined;
26
27
  /** Names the links' collection; with `applyOp`, turns on add/remove. */
27
28
  collection?: CollectionRef;
@@ -1,9 +1,10 @@
1
1
  import type { EntityOp, PropertyDescriptor } from '../../edit/types.js';
2
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.
3
+ * What the CMS may edit on one nav/footer link beyond its LABEL: the href and
4
+ * the display order join the label in ONE modal (LinkEdit — clicking the
5
+ * entry opens it), and removal arrives injected by the list from its
6
+ * `collection`. The modal is entered through the label's `editFor`
7
+ * descriptor, so these rows take effect alongside it.
7
8
  */
8
9
  export interface SiteLinkEditMap {
9
10
  href?: PropertyDescriptor;
@@ -11,6 +11,8 @@
11
11
  <script lang="ts">
12
12
  import { getUiConfig } from '../../config/context.js';
13
13
  import ActionLabel from '../../edit/ActionLabel.svelte';
14
+ import LinkEdit from '../../edit/chrome/LinkEdit.svelte';
15
+ import { chromeProperty } from '../../edit/helpers.js';
14
16
  import Editable from '../../edit/Editable.svelte';
15
17
  import { COMMENT_BODY } from '../../forms/constraints.js';
16
18
  import type { CommentData, CommentThreadData } from '../../content/types.js';
@@ -52,6 +54,14 @@
52
54
  const main = $derived(commentForm);
53
55
 
54
56
  const errorMessages = $derived({ unauthenticated: msg.comments_loginPrompt() });
57
+
58
+ // Optional catalog keys; hosts without them keep the built-in paths.
59
+ const signupHref = $derived(msg.comments_signupLinkHref?.() ?? '/signup');
60
+ const loginHref = $derived(msg.comments_loginLinkHref?.() ?? '/login');
61
+ const hrefProperty = (key: string) =>
62
+ config.messageEdit
63
+ ? chromeProperty(key, { type: 'text', label: config.editMessages.edit_linkUrl() })
64
+ : undefined;
55
65
  </script>
56
66
 
57
67
  {#snippet commentMeta(comment: CommentData)}
@@ -180,19 +190,22 @@
180
190
  >
181
191
  {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
182
192
  </Editable>
183
- <ActionLabel
184
- edit={config.messageEdit?.('comments_loginLink')}
185
- value={msg.comments_loginLink()}
193
+ <LinkEdit
194
+ text={{ edit: config.messageEdit?.('comments_loginLink'), value: msg.comments_loginLink() }}
195
+ href={{ descriptor: hrefProperty('comments_loginLinkHref'), value: loginHref }}
186
196
  >
187
- {#snippet control()}<Link href="/login">{msg.comments_loginLink()}</Link>{/snippet}
188
- </ActionLabel>
197
+ {#snippet control()}<Link href={loginHref}>{msg.comments_loginLink()}</Link>{/snippet}
198
+ </LinkEdit>
189
199
  ·
190
- <ActionLabel
191
- edit={config.messageEdit?.('comments_signupLink')}
192
- value={msg.comments_signupLink()}
200
+ <LinkEdit
201
+ text={{
202
+ edit: config.messageEdit?.('comments_signupLink'),
203
+ value: msg.comments_signupLink()
204
+ }}
205
+ href={{ descriptor: hrefProperty('comments_signupLinkHref'), value: signupHref }}
193
206
  >
194
- {#snippet control()}<Link href="/signup">{msg.comments_signupLink()}</Link>{/snippet}
195
- </ActionLabel>
207
+ {#snippet control()}<Link href={signupHref}>{msg.comments_signupLink()}</Link>{/snippet}
208
+ </LinkEdit>
196
209
  </p>
197
210
  {/if}
198
211
  </section>
@@ -40,6 +40,12 @@ export interface EditMessages {
40
40
  edit_draftBadge(): string;
41
41
  edit_clearValue(): string;
42
42
  edit_emptyRequired(): string;
43
+ /** The link modal: one dialog edits a link's text and destination. */
44
+ edit_editLink(params: {
45
+ label: string;
46
+ }): string;
47
+ edit_linkText(): string;
48
+ edit_linkUrl(): string;
43
49
  }
44
50
  /**
45
51
  * Catalan defaults. The first three and the RichText strings reproduce the
@@ -21,5 +21,8 @@ export const defaultEditMessages = {
21
21
  edit_uploadImage: () => 'Puja una imatge',
22
22
  edit_draftBadge: () => 'Esborrany',
23
23
  edit_clearValue: () => 'Buida el valor',
24
- edit_emptyRequired: () => 'Aquest camp no pot quedar buit'
24
+ edit_emptyRequired: () => 'Aquest camp no pot quedar buit',
25
+ edit_editLink: ({ label }) => `Edita l’enllaç «${label}»`,
26
+ edit_linkText: () => 'Text',
27
+ edit_linkUrl: () => 'Adreça'
25
28
  };
@@ -82,6 +82,8 @@ export const defaultMessages = {
82
82
  comments_loginPrompt: () => 'Per comentar cal iniciar la sessió.',
83
83
  comments_loginLink: () => 'Inicia la sessió',
84
84
  comments_signupLink: () => 'Crea un compte',
85
+ comments_loginLinkHref: () => '/login',
86
+ comments_signupLinkHref: () => '/signup',
85
87
  reaction_like: () => "M'agrada",
86
88
  reaction_love: () => "M'encanta",
87
89
  reaction_clap: () => 'Aplaudiment',
@@ -114,6 +114,13 @@ export interface UiMessages {
114
114
  comments_loginPrompt(): string;
115
115
  comments_loginLink(): string;
116
116
  comments_signupLink(): string;
117
+ /**
118
+ * OPTIONAL — the login/signup destinations, added after the catalogs these
119
+ * components first shipped with. A host without the keys keeps the
120
+ * built-in /login and /signup paths, byte-identically.
121
+ */
122
+ comments_loginLinkHref?(): string;
123
+ comments_signupLinkHref?(): string;
117
124
  reaction_like(): string;
118
125
  reaction_love(): string;
119
126
  reaction_clap(): string;
@@ -0,0 +1,266 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Button from '../../components/ui/Button.svelte';
4
+ import Icon from '../../components/ui/Icon.svelte';
5
+ import Modal from '../../components/ui/Modal.svelte';
6
+ import { getUiConfig } from '../../config/context.js';
7
+ import { getEditAdapter } from '../context.js';
8
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
9
+ import ConfirmDialog from './ConfirmDialog.svelte';
10
+
11
+ /**
12
+ * A LINK edited whole: one modal for its text and its destination, instead
13
+ * of an inline label swap plus a separate property panel — a link's two
14
+ * halves belong to one gesture.
15
+ *
16
+ * While the adapter is editing and a text descriptor is supplied, the
17
+ * control is REPLACED by a button wearing the label (the ActionLabel rule:
18
+ * a caret or a click inside a live link would navigate mid-edit) that
19
+ * opens the modal. Desa commits ONLY the halves that changed — the text
20
+ * through `adapter.save`, the destination through `adapter.saveProperty` —
21
+ * so a failure on one keeps the other's success and the modal stays open
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.
26
+ */
27
+ interface Props {
28
+ /** Inline-label half; undefined leaves the control alone. */
29
+ text: { edit: EditDescriptor | undefined; value: string };
30
+ /** Destination half; undefined hides the Adreça field. */
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;
39
+ /** Accessible name for the modal, e.g. the link's current text. */
40
+ label?: string;
41
+ control: Snippet;
42
+ }
43
+
44
+ let {
45
+ text,
46
+ href,
47
+ extras = undefined,
48
+ removeOp = undefined,
49
+ label = undefined,
50
+ control
51
+ }: Props = $props();
52
+
53
+ const adapter = getEditAdapter();
54
+ const config = getUiConfig();
55
+ const messages = $derived(config.editMessages);
56
+
57
+ const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
58
+ // Every field beyond the text needs both a place and a verb to save with.
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);
62
+
63
+ let open = $state(false);
64
+ let draftText = $state('');
65
+ let draftHref = $state('');
66
+ let draftExtras = $state<string[]>([]);
67
+ let saving = $state(false);
68
+ let confirmingRemove = $state(false);
69
+ let removing = $state(false);
70
+ let error = $state<string | null>(null);
71
+
72
+ function show(): void {
73
+ draftText = text.value;
74
+ draftHref = href.value;
75
+ draftExtras = editableExtras.map((extra) => extra.value ?? '');
76
+ error = null;
77
+ open = true;
78
+ }
79
+
80
+ /** Escape/Cancel·la/backdrop: discard the drafts, keep the stored values. */
81
+ function cancel(): void {
82
+ if (saving || removing) return;
83
+ open = false;
84
+ }
85
+
86
+ async function save(): Promise<void> {
87
+ if (!adapter || text.edit === undefined) return;
88
+ const nextText = draftText.trim();
89
+ const nextHref = draftHref.trim();
90
+ saving = true;
91
+ error = null;
92
+ try {
93
+ // Sequential on purpose: if the text lands and the address fails, the
94
+ // reseeded draft no longer differs, so a retry commits only what is
95
+ // still pending.
96
+ if (nextText !== text.value) {
97
+ await adapter.save(text.edit, nextText);
98
+ draftText = nextText;
99
+ }
100
+ if (editsHref && href.descriptor && nextHref !== href.value) {
101
+ await adapter.saveProperty!(href.descriptor, nextHref);
102
+ draftHref = nextHref;
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
+ }
110
+ open = false;
111
+ } catch (thrown) {
112
+ error =
113
+ thrown instanceof Error && thrown.message ? thrown.message : messages.edit_saveError();
114
+ } finally {
115
+ saving = false;
116
+ }
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
+ }
136
+ </script>
137
+
138
+ {#if editing}
139
+ <button
140
+ type="button"
141
+ class="link-swap"
142
+ data-vit-editing="idle"
143
+ aria-label={messages.edit_editLink({ label: label ?? text.value })}
144
+ onclick={show}
145
+ >
146
+ {text.value}
147
+ <Icon name="pencil" size={13} />
148
+ </button>
149
+ <Modal
150
+ {open}
151
+ title={messages.edit_editLink({ label: label ?? text.value })}
152
+ onclose={cancel}
153
+ closeLabel={messages.edit_close()}
154
+ >
155
+ <div class="fields">
156
+ <label>
157
+ <span>{messages.edit_linkText()}</span>
158
+ <input type="text" bind:value={draftText} disabled={saving} />
159
+ </label>
160
+ {#if editsHref}
161
+ <label>
162
+ <span>{messages.edit_linkUrl()}</span>
163
+ <!-- type=text, not url: site destinations are relative paths. -->
164
+ <input type="text" bind:value={draftHref} disabled={saving} />
165
+ </label>
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}
173
+ </div>
174
+ {#if error}
175
+ <p class="error" role="alert">{error}</p>
176
+ {/if}
177
+ <div class="actions">
178
+ <Button pending={saving ? 1 : 0} onclick={() => void save()}>{messages.edit_save()}</Button>
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}
187
+ </div>
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}
200
+ {:else}
201
+ {@render control()}
202
+ {/if}
203
+
204
+ <style>
205
+ /* The ActionLabel footprint plus a pencil hint: the same dashed affordance
206
+ (base.css paints [data-vit-editing]), but a click OPENS rather than
207
+ focuses — this swap is a button, never a caret. */
208
+ .link-swap {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ gap: var(--space-1);
212
+ padding: 0.2em 0.4em;
213
+ border: none;
214
+ background: transparent;
215
+ font: inherit;
216
+ color: inherit;
217
+ cursor: pointer;
218
+ }
219
+
220
+ .fields {
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: var(--space-3);
224
+ }
225
+
226
+ label {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: var(--space-1);
230
+ font-size: var(--text-sm);
231
+ font-weight: 600;
232
+ }
233
+
234
+ /* PropertyRow's control look — the modal is the panel, relocated. */
235
+ input {
236
+ font: inherit;
237
+ font-size: var(--text-sm);
238
+ font-weight: 400;
239
+ color: var(--color-ink);
240
+ padding: var(--space-1) var(--space-2);
241
+ border: 1px solid var(--color-hairline);
242
+ border-radius: var(--radius);
243
+ background: var(--color-surface);
244
+ width: 100%;
245
+ }
246
+
247
+ .error {
248
+ margin: var(--space-3) 0 0;
249
+ color: var(--series-8);
250
+ font-size: var(--text-sm);
251
+ }
252
+
253
+ .actions {
254
+ display: flex;
255
+ gap: var(--space-2);
256
+ margin-top: var(--space-4);
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
+ }
266
+ </style>
@@ -0,0 +1,46 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
3
+ /**
4
+ * A LINK edited whole: one modal for its text and its destination, instead
5
+ * of an inline label swap plus a separate property panel — a link's two
6
+ * halves belong to one gesture.
7
+ *
8
+ * While the adapter is editing and a text descriptor is supplied, the
9
+ * control is REPLACED by a button wearing the label (the ActionLabel rule:
10
+ * a caret or a click inside a live link would navigate mid-edit) that
11
+ * opens the modal. Desa commits ONLY the halves that changed — the text
12
+ * through `adapter.save`, the destination through `adapter.saveProperty` —
13
+ * so a failure on one keeps the other's success and the modal stays open
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.
18
+ */
19
+ interface Props {
20
+ /** Inline-label half; undefined leaves the control alone. */
21
+ text: {
22
+ edit: EditDescriptor | undefined;
23
+ value: string;
24
+ };
25
+ /** Destination half; undefined hides the Adreça field. */
26
+ href: {
27
+ descriptor: PropertyDescriptor | undefined;
28
+ value: string;
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;
40
+ /** Accessible name for the modal, e.g. the link's current text. */
41
+ label?: string;
42
+ control: Snippet;
43
+ }
44
+ declare const LinkEdit: import("svelte").Component<Props, {}, "">;
45
+ type LinkEdit = ReturnType<typeof LinkEdit>;
46
+ export default LinkEdit;
@@ -7,6 +7,7 @@ export { default as EditFrame } from './chrome/EditFrame.svelte';
7
7
  export type { EditFrameSpec } from './chrome/EditFrame.svelte';
8
8
  export { default as EditPanel } from './chrome/EditPanel.svelte';
9
9
  export { default as EditPopover } from './chrome/EditPopover.svelte';
10
+ export { default as LinkEdit } from './chrome/LinkEdit.svelte';
10
11
  export { chromeEdit, chromeProperty, collectionOf, entityEdit, entityProperty, pageCopyEdit } from './helpers.js';
11
12
  export { localize } from './types.js';
12
13
  export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType } from './types.js';
@@ -6,5 +6,6 @@ export { default as ConfirmDialog } from './chrome/ConfirmDialog.svelte';
6
6
  export { default as EditFrame } from './chrome/EditFrame.svelte';
7
7
  export { default as EditPanel } from './chrome/EditPanel.svelte';
8
8
  export { default as EditPopover } from './chrome/EditPopover.svelte';
9
+ export { default as LinkEdit } from './chrome/LinkEdit.svelte';
9
10
  export { chromeEdit, chromeProperty, collectionOf, entityEdit, entityProperty, pageCopyEdit } from './helpers.js';
10
11
  export { localize } from './types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",