@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.
- package/dist/components/account/NewsletterSignup.svelte +31 -10
- package/dist/components/layout/Footer.svelte +12 -27
- package/dist/components/layout/Footer.svelte.d.ts +1 -1
- package/dist/components/layout/Nav.svelte +19 -31
- package/dist/components/layout/Nav.svelte.d.ts +4 -3
- package/dist/components/layout/site-link-edit.d.ts +5 -4
- package/dist/components/weeklies/CommentSection.svelte +23 -10
- package/dist/config/edit-messages.d.ts +6 -0
- package/dist/config/edit-messages.js +4 -1
- package/dist/config/messages.js +2 -0
- package/dist/config/types.d.ts +7 -0
- package/dist/edit/chrome/LinkEdit.svelte +266 -0
- package/dist/edit/chrome/LinkEdit.svelte.d.ts +46 -0
- package/dist/edit/index.d.ts +1 -0
- package/dist/edit/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
69
|
+
{#snippet control()}<Link href={withNewsletterIntent(signupHref)}
|
|
52
70
|
>{msg.comments_signupLink()}</Link
|
|
53
71
|
>{/snippet}
|
|
54
|
-
</
|
|
72
|
+
</LinkEdit>
|
|
55
73
|
·
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
81
|
+
{#snippet control()}<Link href={withNewsletterIntent(loginHref)}
|
|
61
82
|
>{msg.comments_loginLink()}</Link
|
|
62
83
|
>{/snippet}
|
|
63
|
-
</
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
<!--
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
<!--
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
137
|
-
<
|
|
129
|
+
{#snippet control()}
|
|
130
|
+
<Link href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
|
|
131
|
+
{link.label}
|
|
132
|
+
</Link>
|
|
138
133
|
{/snippet}
|
|
139
|
-
|
|
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
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `collection
|
|
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
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
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=
|
|
188
|
-
</
|
|
197
|
+
{#snippet control()}<Link href={loginHref}>{msg.comments_loginLink()}</Link>{/snippet}
|
|
198
|
+
</LinkEdit>
|
|
189
199
|
·
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
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=
|
|
195
|
-
</
|
|
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
|
};
|
package/dist/config/messages.js
CHANGED
|
@@ -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',
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/edit/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/edit/index.js
CHANGED
|
@@ -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';
|