@vit-foundation/ui 0.14.0 → 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.
@@ -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;
@@ -5,7 +5,8 @@
5
5
  import Modal from '../../components/ui/Modal.svelte';
6
6
  import { getUiConfig } from '../../config/context.js';
7
7
  import { getEditAdapter } from '../context.js';
8
- import type { EditDescriptor, PropertyDescriptor } from '../types.js';
8
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
9
+ import ConfirmDialog from './ConfirmDialog.svelte';
9
10
 
10
11
  /**
11
12
  * A LINK edited whole: one modal for its text and its destination, instead
@@ -18,45 +19,67 @@
18
19
  * opens the modal. Desa commits ONLY the halves that changed — the text
19
20
  * through `adapter.save`, the destination through `adapter.saveProperty` —
20
21
  * 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.
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.
23
26
  */
24
27
  interface Props {
25
28
  /** Inline-label half; undefined leaves the control alone. */
26
29
  text: { edit: EditDescriptor | undefined; value: string };
27
30
  /** Destination half; undefined hides the Adreça field. */
28
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;
29
39
  /** Accessible name for the modal, e.g. the link's current text. */
30
40
  label?: string;
31
41
  control: Snippet;
32
42
  }
33
43
 
34
- let { text, href, label = undefined, control }: Props = $props();
44
+ let {
45
+ text,
46
+ href,
47
+ extras = undefined,
48
+ removeOp = undefined,
49
+ label = undefined,
50
+ control
51
+ }: Props = $props();
35
52
 
36
53
  const adapter = getEditAdapter();
37
54
  const config = getUiConfig();
38
55
  const messages = $derived(config.editMessages);
39
56
 
40
57
  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.
58
+ // Every field beyond the text needs both a place and a verb to save with.
42
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);
43
62
 
44
63
  let open = $state(false);
45
64
  let draftText = $state('');
46
65
  let draftHref = $state('');
66
+ let draftExtras = $state<string[]>([]);
47
67
  let saving = $state(false);
68
+ let confirmingRemove = $state(false);
69
+ let removing = $state(false);
48
70
  let error = $state<string | null>(null);
49
71
 
50
72
  function show(): void {
51
73
  draftText = text.value;
52
74
  draftHref = href.value;
75
+ draftExtras = editableExtras.map((extra) => extra.value ?? '');
53
76
  error = null;
54
77
  open = true;
55
78
  }
56
79
 
57
80
  /** Escape/Cancel·la/backdrop: discard the drafts, keep the stored values. */
58
81
  function cancel(): void {
59
- if (saving) return;
82
+ if (saving || removing) return;
60
83
  open = false;
61
84
  }
62
85
 
@@ -78,6 +101,12 @@
78
101
  await adapter.saveProperty!(href.descriptor, nextHref);
79
102
  draftHref = nextHref;
80
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
+ }
81
110
  open = false;
82
111
  } catch (thrown) {
83
112
  error =
@@ -86,6 +115,24 @@
86
115
  saving = false;
87
116
  }
88
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
+ }
89
136
  </script>
90
137
 
91
138
  {#if editing}
@@ -117,6 +164,12 @@
117
164
  <input type="text" bind:value={draftHref} disabled={saving} />
118
165
  </label>
119
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}
120
173
  </div>
121
174
  {#if error}
122
175
  <p class="error" role="alert">{error}</p>
@@ -124,8 +177,26 @@
124
177
  <div class="actions">
125
178
  <Button pending={saving ? 1 : 0} onclick={() => void save()}>{messages.edit_save()}</Button>
126
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}
127
187
  </div>
128
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}
129
200
  {:else}
130
201
  {@render control()}
131
202
  {/if}
@@ -184,4 +255,12 @@
184
255
  gap: var(--space-2);
185
256
  margin-top: var(--space-4);
186
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
+ }
187
266
  </style>
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { EditDescriptor, PropertyDescriptor } from '../types.js';
2
+ import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
3
3
  /**
4
4
  * A LINK edited whole: one modal for its text and its destination, instead
5
5
  * of an inline label swap plus a separate property panel — a link's two
@@ -11,8 +11,10 @@ import type { EditDescriptor, PropertyDescriptor } from '../types.js';
11
11
  * opens the modal. Desa commits ONLY the halves that changed — the text
12
12
  * through `adapter.save`, the destination through `adapter.saveProperty` —
13
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.
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.
16
18
  */
17
19
  interface Props {
18
20
  /** Inline-label half; undefined leaves the control alone. */
@@ -25,6 +27,16 @@ interface Props {
25
27
  descriptor: PropertyDescriptor | undefined;
26
28
  value: string;
27
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;
28
40
  /** Accessible name for the modal, e.g. the link's current text. */
29
41
  label?: string;
30
42
  control: Snippet;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",