@vit-foundation/ui 0.10.1 → 0.12.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/contact/ContactForm.svelte +44 -14
- package/dist/components/jobs/JobList.svelte +73 -12
- package/dist/components/jobs/JobList.svelte.d.ts +19 -2
- package/dist/components/projects/ProjectCard.svelte +78 -33
- package/dist/components/projects/ProjectCard.svelte.d.ts +11 -2
- package/dist/components/team/CollaboratorList.svelte +87 -9
- package/dist/components/team/CollaboratorList.svelte.d.ts +26 -0
- package/dist/components/team/TeamMemberCard.svelte +41 -19
- package/dist/components/team/TeamMemberCard.svelte.d.ts +8 -4
- package/dist/components/timeline/Timeline.svelte +47 -2
- package/dist/components/timeline/Timeline.svelte.d.ts +8 -0
- package/dist/components/timeline/TimelineMilestone.svelte +94 -38
- package/dist/components/timeline/TimelineMilestone.svelte.d.ts +16 -2
- package/dist/components/ui/RichText.svelte +12 -6
- package/dist/components/ui/SearchInput.svelte +31 -10
- package/dist/components/ui/SearchInput.svelte.d.ts +7 -0
- package/dist/components/weeklies/SortSelect.svelte +41 -11
- package/dist/components/weeklies/SortSelect.svelte.d.ts +8 -0
- package/dist/components/weeklies/WeeklieCard.svelte +44 -19
- package/dist/components/weeklies/WeeklieCard.svelte.d.ts +9 -2
- package/dist/config/context.js +6 -1
- package/dist/config/edit-messages.d.ts +47 -0
- package/dist/config/edit-messages.js +24 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +1 -0
- package/dist/config/types.d.ts +8 -0
- package/dist/content/types.d.ts +4 -0
- package/dist/edit/Editable.svelte +5 -3
- package/dist/edit/chrome/AddSlot.svelte +89 -0
- package/dist/edit/chrome/AddSlot.svelte.d.ts +17 -0
- package/dist/edit/chrome/ConfirmDialog.svelte +65 -0
- package/dist/edit/chrome/ConfirmDialog.svelte.d.ts +18 -0
- package/dist/edit/chrome/EditFrame.svelte +171 -0
- package/dist/edit/chrome/EditFrame.svelte.d.ts +33 -0
- package/dist/edit/chrome/EditPanel.svelte +29 -0
- package/dist/edit/chrome/EditPanel.svelte.d.ts +15 -0
- package/dist/edit/chrome/EditPopover.svelte +57 -0
- package/dist/edit/chrome/EditPopover.svelte.d.ts +17 -0
- package/dist/edit/chrome/PropertyRow.svelte +241 -0
- package/dist/edit/chrome/PropertyRow.svelte.d.ts +17 -0
- package/dist/edit/helpers.d.ts +19 -1
- package/dist/edit/helpers.js +21 -0
- package/dist/edit/index.d.ts +8 -2
- package/dist/edit/index.js +6 -1
- package/dist/edit/types.d.ts +82 -2
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor chrome's own strings — status announcements, panel labels, the
|
|
3
|
+
* confirm-delete wording. A separate object from `UiMessages` on purpose:
|
|
4
|
+
* that interface mirrors the website's wording catalog (its keys are
|
|
5
|
+
* `ui_messages` rows a CMS edits), while these strings belong to the EDITOR
|
|
6
|
+
* a CMS host renders — they must never leak into the site's catalog, and
|
|
7
|
+
* they must never themselves become editable chrome.
|
|
8
|
+
*
|
|
9
|
+
* Same message-function style as `UiMessages`, and the same all-or-nothing
|
|
10
|
+
* override: a host replaces the whole object or keeps the Catalan defaults.
|
|
11
|
+
*/
|
|
12
|
+
export interface EditMessages {
|
|
13
|
+
/** Inline + panel status announcements. */
|
|
14
|
+
edit_saving(): string;
|
|
15
|
+
edit_saved(): string;
|
|
16
|
+
edit_saveError(): string;
|
|
17
|
+
/** RichText's source editor. */
|
|
18
|
+
edit_save(): string;
|
|
19
|
+
edit_cancel(): string;
|
|
20
|
+
edit_edit(): string;
|
|
21
|
+
edit_preview(): string;
|
|
22
|
+
edit_editBody(): string;
|
|
23
|
+
/** The frame toolbar and its panel. */
|
|
24
|
+
edit_properties(params: {
|
|
25
|
+
label: string;
|
|
26
|
+
}): string;
|
|
27
|
+
edit_remove(): string;
|
|
28
|
+
edit_removeConfirm(params: {
|
|
29
|
+
label: string;
|
|
30
|
+
}): string;
|
|
31
|
+
edit_close(): string;
|
|
32
|
+
/** Collections. */
|
|
33
|
+
edit_add(params: {
|
|
34
|
+
label: string;
|
|
35
|
+
}): string;
|
|
36
|
+
edit_addFailed(): string;
|
|
37
|
+
/** Property rows. */
|
|
38
|
+
edit_uploadImage(): string;
|
|
39
|
+
edit_clearValue(): string;
|
|
40
|
+
edit_emptyRequired(): string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Catalan defaults. The first three and the RichText strings reproduce the
|
|
44
|
+
* previously hardcoded copy byte-identically — swapping the source of a
|
|
45
|
+
* string must not repaint a single test or page.
|
|
46
|
+
*/
|
|
47
|
+
export declare const defaultEditMessages: EditMessages;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalan defaults. The first three and the RichText strings reproduce the
|
|
3
|
+
* previously hardcoded copy byte-identically — swapping the source of a
|
|
4
|
+
* string must not repaint a single test or page.
|
|
5
|
+
*/
|
|
6
|
+
export const defaultEditMessages = {
|
|
7
|
+
edit_saving: () => 'Desant…',
|
|
8
|
+
edit_saved: () => 'Desat',
|
|
9
|
+
edit_saveError: () => 'Error en desar',
|
|
10
|
+
edit_save: () => 'Desa',
|
|
11
|
+
edit_cancel: () => 'Cancel·la',
|
|
12
|
+
edit_edit: () => 'Edita',
|
|
13
|
+
edit_preview: () => 'Vista prèvia',
|
|
14
|
+
edit_editBody: () => 'Edita el contingut',
|
|
15
|
+
edit_properties: ({ label }) => `Propietats: ${label}`,
|
|
16
|
+
edit_remove: () => 'Elimina',
|
|
17
|
+
edit_removeConfirm: ({ label }) => `Segur que vols eliminar «${label}»? Es pot desfer des de l’historial.`,
|
|
18
|
+
edit_close: () => 'Tanca',
|
|
19
|
+
edit_add: ({ label }) => `Afegeix ${label}`,
|
|
20
|
+
edit_addFailed: () => 'No s’ha pogut afegir',
|
|
21
|
+
edit_uploadImage: () => 'Puja una imatge',
|
|
22
|
+
edit_clearValue: () => 'Buida el valor',
|
|
23
|
+
edit_emptyRequired: () => 'Aquest camp no pot quedar buit'
|
|
24
|
+
};
|
package/dist/config/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { DEFAULT_UI_CONFIG, getUiConfig, setUiConfig } from './context.js';
|
|
2
|
+
export { defaultEditMessages } from './edit-messages.js';
|
|
3
|
+
export type { EditMessages } from './edit-messages.js';
|
|
2
4
|
export { defaultMessages } from './messages.js';
|
|
3
5
|
export { default as UiProvider } from './UiProvider.svelte';
|
|
4
6
|
export { BASE_LOCALE, LOCALES } from './types.js';
|
package/dist/config/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_UI_CONFIG, getUiConfig, setUiConfig } from './context.js';
|
|
2
|
+
export { defaultEditMessages } from './edit-messages.js';
|
|
2
3
|
export { defaultMessages } from './messages.js';
|
|
3
4
|
export { default as UiProvider } from './UiProvider.svelte';
|
|
4
5
|
export { BASE_LOCALE, LOCALES } from './types.js';
|
package/dist/config/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EditDescriptor } from '../edit/types.js';
|
|
2
|
+
import type { EditMessages } from './edit-messages.js';
|
|
2
3
|
/**
|
|
3
4
|
* The site's locales and their canonical member. Fixed rather than generic:
|
|
4
5
|
* `ca` is load-bearing — it is the required key of every localized column
|
|
@@ -157,6 +158,12 @@ export interface UiConfig {
|
|
|
157
158
|
/** Appended to document titles and shown as the wordmark. */
|
|
158
159
|
siteName: string;
|
|
159
160
|
messages: UiMessages;
|
|
161
|
+
/**
|
|
162
|
+
* The editor chrome's own strings (status announcements, panel wording) —
|
|
163
|
+
* resolved, Catalan by default. NOT part of `messages`: see
|
|
164
|
+
* `./edit-messages.js` for why the two catalogs must not mix.
|
|
165
|
+
*/
|
|
166
|
+
editMessages: EditMessages;
|
|
160
167
|
/**
|
|
161
168
|
* Edit descriptor for one of THIS config's message strings, by catalog
|
|
162
169
|
* key — the interface-wording half of edit mode. Optional and undefined by
|
|
@@ -175,5 +182,6 @@ export interface UiConfigInput {
|
|
|
175
182
|
canonicalPathname?: UiConfig['canonicalPathname'];
|
|
176
183
|
siteName?: string;
|
|
177
184
|
messages?: UiMessages;
|
|
185
|
+
editMessages?: EditMessages;
|
|
178
186
|
messageEdit?: UiConfig['messageEdit'];
|
|
179
187
|
}
|
package/dist/content/types.d.ts
CHANGED
|
@@ -59,11 +59,15 @@ export interface TeamMemberData {
|
|
|
59
59
|
isBoard: boolean;
|
|
60
60
|
}
|
|
61
61
|
export interface CollaboratorData {
|
|
62
|
+
/** Present only where the list is editable — remove ops need an identity. */
|
|
63
|
+
id?: string | number;
|
|
62
64
|
personName: string;
|
|
63
65
|
affiliation: string;
|
|
64
66
|
url: string | null;
|
|
65
67
|
}
|
|
66
68
|
export interface JobOpeningData {
|
|
69
|
+
/** Present only where the list is editable — remove ops need an identity. */
|
|
70
|
+
id?: string | number;
|
|
67
71
|
slug: string;
|
|
68
72
|
title: string;
|
|
69
73
|
description: string | null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { getUiConfig } from '../config/context.js';
|
|
3
4
|
import { getEditAdapter } from './context.js';
|
|
4
5
|
import type { EditDescriptor } from './types.js';
|
|
5
6
|
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
let { edit, value, children }: Props = $props();
|
|
56
57
|
|
|
57
58
|
const adapter = getEditAdapter();
|
|
59
|
+
const config = getUiConfig();
|
|
58
60
|
|
|
59
61
|
let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
|
|
60
62
|
/** Last persisted value: what Escape restores and blur diffs against. */
|
|
@@ -121,16 +123,16 @@
|
|
|
121
123
|
return;
|
|
122
124
|
}
|
|
123
125
|
status = 'saving';
|
|
124
|
-
announcement =
|
|
126
|
+
announcement = config.editMessages.edit_saving();
|
|
125
127
|
try {
|
|
126
128
|
await adapter.save(edit, draft);
|
|
127
129
|
savedValue = draft;
|
|
128
130
|
status = 'idle';
|
|
129
|
-
announcement =
|
|
131
|
+
announcement = config.editMessages.edit_saved();
|
|
130
132
|
} catch {
|
|
131
133
|
// Draft stays in the DOM; the reader decides whether to retry.
|
|
132
134
|
status = 'error';
|
|
133
|
-
announcement =
|
|
135
|
+
announcement = config.editMessages.edit_saveError();
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from '../../components/ui/Icon.svelte';
|
|
3
|
+
import { getUiConfig } from '../../config/context.js';
|
|
4
|
+
import { getEditAdapter } from '../context.js';
|
|
5
|
+
import type { EntityOp } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The "+" affordance of an editable collection. Renders ONLY while the
|
|
9
|
+
* adapter is editing AND implements `applyOp` — otherwise nothing, the
|
|
10
|
+
* byte-identical invariant. The op resolves server-side and the host's
|
|
11
|
+
* refresh brings the new row in; this control only shows pending/error.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
op: Extract<EntityOp, { kind: 'create' }>;
|
|
15
|
+
/** The entity noun for the label, e.g. "una fita". */
|
|
16
|
+
label?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let { op, label = '' }: Props = $props();
|
|
20
|
+
|
|
21
|
+
const adapter = getEditAdapter();
|
|
22
|
+
const config = getUiConfig();
|
|
23
|
+
|
|
24
|
+
let pending = $state(false);
|
|
25
|
+
let announcement = $state('');
|
|
26
|
+
|
|
27
|
+
const active = $derived((adapter?.isEditing ?? false) && adapter?.applyOp !== undefined);
|
|
28
|
+
const text = $derived(config.editMessages.edit_add({ label }).trim());
|
|
29
|
+
|
|
30
|
+
async function add(): Promise<void> {
|
|
31
|
+
if (!adapter?.applyOp || pending) return;
|
|
32
|
+
pending = true;
|
|
33
|
+
announcement = '';
|
|
34
|
+
try {
|
|
35
|
+
await adapter.applyOp(op);
|
|
36
|
+
} catch {
|
|
37
|
+
announcement = config.editMessages.edit_addFailed();
|
|
38
|
+
} finally {
|
|
39
|
+
pending = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#if active}
|
|
45
|
+
<button type="button" class="add" disabled={pending} onclick={() => void add()}>
|
|
46
|
+
<Icon name="plus" size={16} />
|
|
47
|
+
{text}
|
|
48
|
+
</button>
|
|
49
|
+
<span class="status" role="status">{announcement}</span>
|
|
50
|
+
{/if}
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.add {
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: var(--space-1);
|
|
57
|
+
padding: var(--space-1) var(--space-3);
|
|
58
|
+
border: 1px dashed var(--color-ink-muted);
|
|
59
|
+
border-radius: 999px;
|
|
60
|
+
background: transparent;
|
|
61
|
+
color: var(--color-ink);
|
|
62
|
+
font: inherit;
|
|
63
|
+
font-size: var(--text-sm);
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
transition: border-color var(--transition-fast);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.add:hover:not(:disabled) {
|
|
69
|
+
border-color: var(--color-brand);
|
|
70
|
+
color: var(--color-brand);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.add:disabled {
|
|
74
|
+
opacity: 0.5;
|
|
75
|
+
cursor: default;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.status {
|
|
79
|
+
position: absolute;
|
|
80
|
+
width: 1px;
|
|
81
|
+
height: 1px;
|
|
82
|
+
padding: 0;
|
|
83
|
+
margin: -1px;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
clip-path: inset(50%);
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
border: 0;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EntityOp } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The "+" affordance of an editable collection. Renders ONLY while the
|
|
4
|
+
* adapter is editing AND implements `applyOp` — otherwise nothing, the
|
|
5
|
+
* byte-identical invariant. The op resolves server-side and the host's
|
|
6
|
+
* refresh brings the new row in; this control only shows pending/error.
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
op: Extract<EntityOp, {
|
|
10
|
+
kind: 'create';
|
|
11
|
+
}>;
|
|
12
|
+
/** The entity noun for the label, e.g. "una fita". */
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const AddSlot: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type AddSlot = ReturnType<typeof AddSlot>;
|
|
17
|
+
export default AddSlot;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from '../../components/ui/Button.svelte';
|
|
3
|
+
import Modal from '../../components/ui/Modal.svelte';
|
|
4
|
+
import { getUiConfig } from '../../config/context.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Destructive confirmation over the Modal primitive: native <dialog>
|
|
8
|
+
* handles top layer, backdrop and Escape. The confirm button turns
|
|
9
|
+
* destructive by overriding the tokens Button's `primary` reads — a theme
|
|
10
|
+
* decision expressed as token values, not a new Button variant.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
open: boolean;
|
|
14
|
+
title: string;
|
|
15
|
+
message: string;
|
|
16
|
+
confirmLabel: string;
|
|
17
|
+
pending?: boolean;
|
|
18
|
+
onconfirm: () => void;
|
|
19
|
+
oncancel: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
open,
|
|
24
|
+
title,
|
|
25
|
+
message,
|
|
26
|
+
confirmLabel,
|
|
27
|
+
pending = false,
|
|
28
|
+
onconfirm,
|
|
29
|
+
oncancel
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
const config = getUiConfig();
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<Modal {open} {title} onclose={oncancel} closeLabel={config.editMessages.edit_close()}>
|
|
36
|
+
<p class="message">{message}</p>
|
|
37
|
+
<div class="actions">
|
|
38
|
+
<span class="danger">
|
|
39
|
+
<Button pending={pending ? 1 : 0} onclick={onconfirm}>{confirmLabel}</Button>
|
|
40
|
+
</span>
|
|
41
|
+
<Button variant="ghost" pending={null} onclick={oncancel}>
|
|
42
|
+
{config.editMessages.edit_cancel()}
|
|
43
|
+
</Button>
|
|
44
|
+
</div>
|
|
45
|
+
</Modal>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.message {
|
|
49
|
+
margin: 0;
|
|
50
|
+
max-width: 44ch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.actions {
|
|
54
|
+
display: flex;
|
|
55
|
+
gap: var(--space-2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Button's primary fill reads --color-brand (hover --color-navy); the
|
|
59
|
+
error hue is --series-8, so overriding both here IS the destructive
|
|
60
|
+
variant — no second styling path inside Button. */
|
|
61
|
+
.danger {
|
|
62
|
+
--color-brand: var(--series-8);
|
|
63
|
+
--color-navy: color-mix(in srgb, var(--series-8) 80%, var(--color-ink));
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destructive confirmation over the Modal primitive: native <dialog>
|
|
3
|
+
* handles top layer, backdrop and Escape. The confirm button turns
|
|
4
|
+
* destructive by overriding the tokens Button's `primary` reads — a theme
|
|
5
|
+
* decision expressed as token values, not a new Button variant.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
open: boolean;
|
|
9
|
+
title: string;
|
|
10
|
+
message: string;
|
|
11
|
+
confirmLabel: string;
|
|
12
|
+
pending?: boolean;
|
|
13
|
+
onconfirm: () => void;
|
|
14
|
+
oncancel: () => void;
|
|
15
|
+
}
|
|
16
|
+
declare const ConfirmDialog: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
|
|
18
|
+
export default ConfirmDialog;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import IconButton from '../../components/ui/IconButton.svelte';
|
|
4
|
+
import { getUiConfig } from '../../config/context.js';
|
|
5
|
+
import { getEditAdapter } from '../context.js';
|
|
6
|
+
import type { EntityOp } from '../types.js';
|
|
7
|
+
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
8
|
+
import EditPopover from './EditPopover.svelte';
|
|
9
|
+
|
|
10
|
+
/** What one framed component tells the frame about itself. */
|
|
11
|
+
export interface EditFrameSpec {
|
|
12
|
+
/** Human name of the thing, e.g. "Fita: Neix la fundació". */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Gates the gear — true when the panel snippet has rows to show. */
|
|
15
|
+
hasPanel?: boolean;
|
|
16
|
+
/** Gates the trash: removing this item from its collection. */
|
|
17
|
+
removeOp?: Extract<EntityOp, { kind: 'remove' }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The page-builder wrapper: a corner toolbar (revealed on hover AND
|
|
22
|
+
* :focus-within — never hover-only) with a gear that opens the property
|
|
23
|
+
* panel and a trash that confirms, then applies the remove op.
|
|
24
|
+
*
|
|
25
|
+
* Triple-gated per affordance: spec present ∧ adapter editing ∧ the
|
|
26
|
+
* capability method present. With nothing to offer it renders the children
|
|
27
|
+
* ALONE — zero wrapper element, so an unadapted app's DOM is byte-identical
|
|
28
|
+
* (pinned by test). Components mount it INSIDE their own root element, so
|
|
29
|
+
* the wrapper never disturbs the parent's flex/grid.
|
|
30
|
+
*/
|
|
31
|
+
interface Props {
|
|
32
|
+
spec?: EditFrameSpec;
|
|
33
|
+
/** The panel content — an EditPanel with this component's rows. */
|
|
34
|
+
panel?: Snippet;
|
|
35
|
+
children: Snippet;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let { spec, panel, children }: Props = $props();
|
|
39
|
+
|
|
40
|
+
const adapter = getEditAdapter();
|
|
41
|
+
const config = getUiConfig();
|
|
42
|
+
|
|
43
|
+
const editing = $derived(spec !== undefined && (adapter?.isEditing ?? false));
|
|
44
|
+
const showGear = $derived(
|
|
45
|
+
editing && spec?.hasPanel === true && panel !== undefined && adapter?.saveProperty !== undefined
|
|
46
|
+
);
|
|
47
|
+
const showTrash = $derived(
|
|
48
|
+
editing && spec?.removeOp !== undefined && adapter?.applyOp !== undefined
|
|
49
|
+
);
|
|
50
|
+
const framed = $derived(showGear || showTrash);
|
|
51
|
+
|
|
52
|
+
let panelOpen = $state(false);
|
|
53
|
+
let confirming = $state(false);
|
|
54
|
+
let removing = $state(false);
|
|
55
|
+
let announcement = $state('');
|
|
56
|
+
let toolbar: HTMLDivElement | undefined = $state();
|
|
57
|
+
|
|
58
|
+
function closePanel(): void {
|
|
59
|
+
panelOpen = false;
|
|
60
|
+
// The popover owned focus; hand it back to the control that opened it.
|
|
61
|
+
toolbar?.querySelector('button')?.focus();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function remove(): Promise<void> {
|
|
65
|
+
if (!adapter?.applyOp || !spec?.removeOp) return;
|
|
66
|
+
removing = true;
|
|
67
|
+
try {
|
|
68
|
+
await adapter.applyOp(spec.removeOp);
|
|
69
|
+
// The host's refresh unmounts this frame with the removed item.
|
|
70
|
+
confirming = false;
|
|
71
|
+
} catch {
|
|
72
|
+
confirming = false;
|
|
73
|
+
announcement = config.editMessages.edit_saveError();
|
|
74
|
+
} finally {
|
|
75
|
+
removing = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
{#if framed && spec}
|
|
81
|
+
<div class="vit-edit-frame">
|
|
82
|
+
{@render children()}
|
|
83
|
+
<div class="toolbar" bind:this={toolbar}>
|
|
84
|
+
{#if showGear}
|
|
85
|
+
<IconButton
|
|
86
|
+
icon="gear"
|
|
87
|
+
label={config.editMessages.edit_properties({ label: spec.label })}
|
|
88
|
+
onclick={() => (panelOpen = !panelOpen)}
|
|
89
|
+
/>
|
|
90
|
+
{/if}
|
|
91
|
+
{#if showTrash}
|
|
92
|
+
<IconButton
|
|
93
|
+
icon="trash"
|
|
94
|
+
label={config.editMessages.edit_remove()}
|
|
95
|
+
onclick={() => (confirming = true)}
|
|
96
|
+
/>
|
|
97
|
+
{/if}
|
|
98
|
+
</div>
|
|
99
|
+
{#if panelOpen && panel}
|
|
100
|
+
<EditPopover
|
|
101
|
+
label={config.editMessages.edit_properties({ label: spec.label })}
|
|
102
|
+
onclose={closePanel}
|
|
103
|
+
>
|
|
104
|
+
{@render panel()}
|
|
105
|
+
</EditPopover>
|
|
106
|
+
{/if}
|
|
107
|
+
{#if showTrash}
|
|
108
|
+
<ConfirmDialog
|
|
109
|
+
open={confirming}
|
|
110
|
+
title={config.editMessages.edit_remove()}
|
|
111
|
+
message={config.editMessages.edit_removeConfirm({ label: spec.label })}
|
|
112
|
+
confirmLabel={config.editMessages.edit_remove()}
|
|
113
|
+
pending={removing}
|
|
114
|
+
onconfirm={() => void remove()}
|
|
115
|
+
oncancel={() => (confirming = false)}
|
|
116
|
+
/>
|
|
117
|
+
{/if}
|
|
118
|
+
<span class="status" role="status">{announcement}</span>
|
|
119
|
+
</div>
|
|
120
|
+
{:else}
|
|
121
|
+
{@render children()}
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
<style>
|
|
125
|
+
.vit-edit-frame {
|
|
126
|
+
position: relative;
|
|
127
|
+
border-radius: var(--radius);
|
|
128
|
+
outline: 1px dashed transparent;
|
|
129
|
+
outline-offset: 4px;
|
|
130
|
+
transition: outline-color var(--transition-fast);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.vit-edit-frame:hover,
|
|
134
|
+
.vit-edit-frame:focus-within {
|
|
135
|
+
outline-color: var(--color-brand);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.toolbar {
|
|
139
|
+
position: absolute;
|
|
140
|
+
top: calc(-1 * var(--space-2));
|
|
141
|
+
right: 0;
|
|
142
|
+
display: flex;
|
|
143
|
+
gap: 2px;
|
|
144
|
+
z-index: var(--z-raised);
|
|
145
|
+
background: var(--color-surface);
|
|
146
|
+
border: 1px solid var(--color-hairline);
|
|
147
|
+
border-radius: var(--radius);
|
|
148
|
+
box-shadow: var(--shadow-1);
|
|
149
|
+
opacity: 0;
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
transition: opacity var(--transition-fast);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.vit-edit-frame:hover .toolbar,
|
|
155
|
+
.vit-edit-frame:focus-within .toolbar {
|
|
156
|
+
opacity: 1;
|
|
157
|
+
pointer-events: auto;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.status {
|
|
161
|
+
position: absolute;
|
|
162
|
+
width: 1px;
|
|
163
|
+
height: 1px;
|
|
164
|
+
padding: 0;
|
|
165
|
+
margin: -1px;
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
clip-path: inset(50%);
|
|
168
|
+
white-space: nowrap;
|
|
169
|
+
border: 0;
|
|
170
|
+
}
|
|
171
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { EntityOp } from '../types.js';
|
|
3
|
+
/** What one framed component tells the frame about itself. */
|
|
4
|
+
export interface EditFrameSpec {
|
|
5
|
+
/** Human name of the thing, e.g. "Fita: Neix la fundació". */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Gates the gear — true when the panel snippet has rows to show. */
|
|
8
|
+
hasPanel?: boolean;
|
|
9
|
+
/** Gates the trash: removing this item from its collection. */
|
|
10
|
+
removeOp?: Extract<EntityOp, {
|
|
11
|
+
kind: 'remove';
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The page-builder wrapper: a corner toolbar (revealed on hover AND
|
|
16
|
+
* :focus-within — never hover-only) with a gear that opens the property
|
|
17
|
+
* panel and a trash that confirms, then applies the remove op.
|
|
18
|
+
*
|
|
19
|
+
* Triple-gated per affordance: spec present ∧ adapter editing ∧ the
|
|
20
|
+
* capability method present. With nothing to offer it renders the children
|
|
21
|
+
* ALONE — zero wrapper element, so an unadapted app's DOM is byte-identical
|
|
22
|
+
* (pinned by test). Components mount it INSIDE their own root element, so
|
|
23
|
+
* the wrapper never disturbs the parent's flex/grid.
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
spec?: EditFrameSpec;
|
|
27
|
+
/** The panel content — an EditPanel with this component's rows. */
|
|
28
|
+
panel?: Snippet;
|
|
29
|
+
children: Snippet;
|
|
30
|
+
}
|
|
31
|
+
declare const EditFrame: import("svelte").Component<Props, {}, "">;
|
|
32
|
+
type EditFrame = ReturnType<typeof EditFrame>;
|
|
33
|
+
export default EditFrame;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PropertyDescriptor } from '../types.js';
|
|
3
|
+
import PropertyRow from './PropertyRow.svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The property form an EditFrame's popover holds: one PropertyRow per
|
|
7
|
+
* entry, each with its own independent commit lifecycle. The component
|
|
8
|
+
* knows nothing about the entity — the descriptors say everything.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
rows: { descriptor: PropertyDescriptor; value: string | null }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { rows }: Props = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="panel">
|
|
18
|
+
{#each rows as row (JSON.stringify(row.descriptor.ref) + row.descriptor.label)}
|
|
19
|
+
<PropertyRow descriptor={row.descriptor} value={row.value} />
|
|
20
|
+
{/each}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.panel {
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
gap: var(--space-3);
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PropertyDescriptor } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The property form an EditFrame's popover holds: one PropertyRow per
|
|
4
|
+
* entry, each with its own independent commit lifecycle. The component
|
|
5
|
+
* knows nothing about the entity — the descriptors say everything.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
rows: {
|
|
9
|
+
descriptor: PropertyDescriptor;
|
|
10
|
+
value: string | null;
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
declare const EditPanel: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type EditPanel = ReturnType<typeof EditPanel>;
|
|
15
|
+
export default EditPanel;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The floating layer an EditFrame opens its panel in. Absolutely
|
|
6
|
+
* positioned INSIDE the frame (no portal: in-flow positioning needs no
|
|
7
|
+
* SSR-unfriendly machinery, and modality is only for destructive confirm,
|
|
8
|
+
* which native <dialog> covers). Escape and outside pointerdown close it;
|
|
9
|
+
* the OPENER owns focus return, because only it knows its own element —
|
|
10
|
+
* the Nav menu pattern.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
label: string;
|
|
14
|
+
onclose: () => void;
|
|
15
|
+
children: Snippet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { label, onclose, children }: Props = $props();
|
|
19
|
+
|
|
20
|
+
let popover: HTMLDivElement | undefined = $state();
|
|
21
|
+
|
|
22
|
+
// Focus the first control so the keyboard lands where the caret goes.
|
|
23
|
+
$effect(() => {
|
|
24
|
+
popover?.querySelector<HTMLElement>('input, select, textarea, button, [tabindex]')?.focus();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function onWindowKeydown(event: KeyboardEvent): void {
|
|
28
|
+
if (event.key === 'Escape') onclose();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onWindowPointerdown(event: PointerEvent): void {
|
|
32
|
+
// The opening click's pointerdown fired before this listener existed,
|
|
33
|
+
// so only genuinely outside presses land here.
|
|
34
|
+
if (popover && !popover.contains(event.target as Node)) onclose();
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<svelte:window onkeydown={onWindowKeydown} onpointerdown={onWindowPointerdown} />
|
|
39
|
+
|
|
40
|
+
<div class="popover" role="dialog" aria-label={label} bind:this={popover}>
|
|
41
|
+
{@render children()}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.popover {
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: var(--space-2);
|
|
48
|
+
right: var(--space-2);
|
|
49
|
+
width: min(20rem, 90vw);
|
|
50
|
+
z-index: var(--z-overlay);
|
|
51
|
+
background: var(--color-surface);
|
|
52
|
+
border: 1px solid var(--color-hairline);
|
|
53
|
+
border-radius: var(--radius);
|
|
54
|
+
box-shadow: var(--shadow-2);
|
|
55
|
+
padding: var(--space-3);
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* The floating layer an EditFrame opens its panel in. Absolutely
|
|
4
|
+
* positioned INSIDE the frame (no portal: in-flow positioning needs no
|
|
5
|
+
* SSR-unfriendly machinery, and modality is only for destructive confirm,
|
|
6
|
+
* which native <dialog> covers). Escape and outside pointerdown close it;
|
|
7
|
+
* the OPENER owns focus return, because only it knows its own element —
|
|
8
|
+
* the Nav menu pattern.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
label: string;
|
|
12
|
+
onclose: () => void;
|
|
13
|
+
children: Snippet;
|
|
14
|
+
}
|
|
15
|
+
declare const EditPopover: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type EditPopover = ReturnType<typeof EditPopover>;
|
|
17
|
+
export default EditPopover;
|