@vit-foundation/ui 0.13.1 → 0.14.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">
@@ -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,187 @@
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, PropertyDescriptor } from '../types.js';
9
+
10
+ /**
11
+ * A LINK edited whole: one modal for its text and its destination, instead
12
+ * of an inline label swap plus a separate property panel — a link's two
13
+ * halves belong to one gesture.
14
+ *
15
+ * While the adapter is editing and a text descriptor is supplied, the
16
+ * control is REPLACED by a button wearing the label (the ActionLabel rule:
17
+ * a caret or a click inside a live link would navigate mid-edit) that
18
+ * opens the modal. Desa commits ONLY the halves that changed — the text
19
+ * through `adapter.save`, the destination through `adapter.saveProperty` —
20
+ * so a failure on one keeps the other's success and the modal stays open
21
+ * with the reason. Without an adapter or a descriptor the control renders
22
+ * untouched, byte-identical to a read-only build.
23
+ */
24
+ interface Props {
25
+ /** Inline-label half; undefined leaves the control alone. */
26
+ text: { edit: EditDescriptor | undefined; value: string };
27
+ /** Destination half; undefined hides the Adreça field. */
28
+ href: { descriptor: PropertyDescriptor | undefined; value: string };
29
+ /** Accessible name for the modal, e.g. the link's current text. */
30
+ label?: string;
31
+ control: Snippet;
32
+ }
33
+
34
+ let { text, href, label = undefined, control }: Props = $props();
35
+
36
+ const adapter = getEditAdapter();
37
+ const config = getUiConfig();
38
+ const messages = $derived(config.editMessages);
39
+
40
+ const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
41
+ // The Adreça field needs both a place to save to and a verb to save with.
42
+ const editsHref = $derived(href.descriptor !== undefined && adapter?.saveProperty !== undefined);
43
+
44
+ let open = $state(false);
45
+ let draftText = $state('');
46
+ let draftHref = $state('');
47
+ let saving = $state(false);
48
+ let error = $state<string | null>(null);
49
+
50
+ function show(): void {
51
+ draftText = text.value;
52
+ draftHref = href.value;
53
+ error = null;
54
+ open = true;
55
+ }
56
+
57
+ /** Escape/Cancel·la/backdrop: discard the drafts, keep the stored values. */
58
+ function cancel(): void {
59
+ if (saving) return;
60
+ open = false;
61
+ }
62
+
63
+ async function save(): Promise<void> {
64
+ if (!adapter || text.edit === undefined) return;
65
+ const nextText = draftText.trim();
66
+ const nextHref = draftHref.trim();
67
+ saving = true;
68
+ error = null;
69
+ try {
70
+ // Sequential on purpose: if the text lands and the address fails, the
71
+ // reseeded draft no longer differs, so a retry commits only what is
72
+ // still pending.
73
+ if (nextText !== text.value) {
74
+ await adapter.save(text.edit, nextText);
75
+ draftText = nextText;
76
+ }
77
+ if (editsHref && href.descriptor && nextHref !== href.value) {
78
+ await adapter.saveProperty!(href.descriptor, nextHref);
79
+ draftHref = nextHref;
80
+ }
81
+ open = false;
82
+ } catch (thrown) {
83
+ error =
84
+ thrown instanceof Error && thrown.message ? thrown.message : messages.edit_saveError();
85
+ } finally {
86
+ saving = false;
87
+ }
88
+ }
89
+ </script>
90
+
91
+ {#if editing}
92
+ <button
93
+ type="button"
94
+ class="link-swap"
95
+ data-vit-editing="idle"
96
+ aria-label={messages.edit_editLink({ label: label ?? text.value })}
97
+ onclick={show}
98
+ >
99
+ {text.value}
100
+ <Icon name="pencil" size={13} />
101
+ </button>
102
+ <Modal
103
+ {open}
104
+ title={messages.edit_editLink({ label: label ?? text.value })}
105
+ onclose={cancel}
106
+ closeLabel={messages.edit_close()}
107
+ >
108
+ <div class="fields">
109
+ <label>
110
+ <span>{messages.edit_linkText()}</span>
111
+ <input type="text" bind:value={draftText} disabled={saving} />
112
+ </label>
113
+ {#if editsHref}
114
+ <label>
115
+ <span>{messages.edit_linkUrl()}</span>
116
+ <!-- type=text, not url: site destinations are relative paths. -->
117
+ <input type="text" bind:value={draftHref} disabled={saving} />
118
+ </label>
119
+ {/if}
120
+ </div>
121
+ {#if error}
122
+ <p class="error" role="alert">{error}</p>
123
+ {/if}
124
+ <div class="actions">
125
+ <Button pending={saving ? 1 : 0} onclick={() => void save()}>{messages.edit_save()}</Button>
126
+ <Button variant="ghost" pending={null} onclick={cancel}>{messages.edit_cancel()}</Button>
127
+ </div>
128
+ </Modal>
129
+ {:else}
130
+ {@render control()}
131
+ {/if}
132
+
133
+ <style>
134
+ /* The ActionLabel footprint plus a pencil hint: the same dashed affordance
135
+ (base.css paints [data-vit-editing]), but a click OPENS rather than
136
+ focuses — this swap is a button, never a caret. */
137
+ .link-swap {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ gap: var(--space-1);
141
+ padding: 0.2em 0.4em;
142
+ border: none;
143
+ background: transparent;
144
+ font: inherit;
145
+ color: inherit;
146
+ cursor: pointer;
147
+ }
148
+
149
+ .fields {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: var(--space-3);
153
+ }
154
+
155
+ label {
156
+ display: flex;
157
+ flex-direction: column;
158
+ gap: var(--space-1);
159
+ font-size: var(--text-sm);
160
+ font-weight: 600;
161
+ }
162
+
163
+ /* PropertyRow's control look — the modal is the panel, relocated. */
164
+ input {
165
+ font: inherit;
166
+ font-size: var(--text-sm);
167
+ font-weight: 400;
168
+ color: var(--color-ink);
169
+ padding: var(--space-1) var(--space-2);
170
+ border: 1px solid var(--color-hairline);
171
+ border-radius: var(--radius);
172
+ background: var(--color-surface);
173
+ width: 100%;
174
+ }
175
+
176
+ .error {
177
+ margin: var(--space-3) 0 0;
178
+ color: var(--series-8);
179
+ font-size: var(--text-sm);
180
+ }
181
+
182
+ .actions {
183
+ display: flex;
184
+ gap: var(--space-2);
185
+ margin-top: var(--space-4);
186
+ }
187
+ </style>
@@ -0,0 +1,34 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { EditDescriptor, 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. Without an adapter or a descriptor the control renders
15
+ * untouched, byte-identical to a read-only build.
16
+ */
17
+ interface Props {
18
+ /** Inline-label half; undefined leaves the control alone. */
19
+ text: {
20
+ edit: EditDescriptor | undefined;
21
+ value: string;
22
+ };
23
+ /** Destination half; undefined hides the Adreça field. */
24
+ href: {
25
+ descriptor: PropertyDescriptor | undefined;
26
+ value: string;
27
+ };
28
+ /** Accessible name for the modal, e.g. the link's current text. */
29
+ label?: string;
30
+ control: Snippet;
31
+ }
32
+ declare const LinkEdit: import("svelte").Component<Props, {}, "">;
33
+ type LinkEdit = ReturnType<typeof LinkEdit>;
34
+ 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.14.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",