@vit-foundation/ui 0.25.0 → 0.26.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/README.md +1 -1
- package/dist/community.d.ts +12 -3
- package/dist/community.js +7 -3
- package/dist/components/pages/ProjectPage.svelte +3 -9
- package/dist/components/pages/ProjectPage.svelte.d.ts +3 -5
- package/dist/components/pages/WeeklyPage.svelte +7 -12
- package/dist/components/pages/WeeklyPage.svelte.d.ts +7 -9
- package/dist/components/ui/Modal.svelte +32 -11
- package/dist/components/ui/Modal.svelte.d.ts +17 -1
- package/dist/config/context.d.ts +0 -6
- package/dist/config/context.js +1 -1
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +1 -1
- package/dist/content-components.d.ts +6 -3
- package/dist/content-components.js +5 -1
- package/dist/contract.d.ts +8 -4
- package/dist/contract.js +7 -3
- package/dist/edit/Editable.svelte +17 -161
- package/dist/edit/Editable.svelte.d.ts +2 -50
- package/dist/edit/chrome/AddSlot.svelte +11 -78
- package/dist/edit/chrome/AddSlot.svelte.d.ts +2 -15
- package/dist/edit/chrome/EditFrame.svelte +15 -158
- package/dist/edit/chrome/EditFrame.svelte.d.ts +2 -31
- package/dist/edit/chrome/EditPanel.svelte +14 -22
- package/dist/edit/chrome/EditPanel.svelte.d.ts +2 -13
- package/dist/edit/chrome/LinkEdit.svelte +13 -277
- package/dist/edit/chrome/LinkEdit.svelte.d.ts +2 -48
- package/dist/edit/chrome-props.d.ts +96 -0
- package/dist/edit/chrome-props.js +1 -0
- package/dist/edit/context.d.ts +38 -5
- package/dist/edit/context.js +18 -7
- package/dist/edit/index.d.ts +6 -4
- package/dist/edit/index.js +4 -3
- package/dist/edit/live/AddSlot.svelte +83 -0
- package/dist/edit/live/AddSlot.svelte.d.ts +4 -0
- package/dist/edit/live/EditFrame.svelte +153 -0
- package/dist/edit/live/EditFrame.svelte.d.ts +4 -0
- package/dist/edit/live/EditPanel.svelte +25 -0
- package/dist/edit/live/EditPanel.svelte.d.ts +4 -0
- package/dist/edit/live/Editable.svelte +162 -0
- package/dist/edit/live/Editable.svelte.d.ts +4 -0
- package/dist/edit/live/LinkEdit.svelte +276 -0
- package/dist/edit/live/LinkEdit.svelte.d.ts +4 -0
- package/dist/edit/{chrome → live}/PropertyRow.svelte +1 -1
- package/dist/edit/{commit.svelte.d.ts → live/commit.svelte.d.ts} +2 -2
- package/dist/edit/live/index.d.ts +10 -0
- package/dist/edit/live/index.js +14 -0
- package/package.json +1 -1
- package/dist/components/account/index.d.ts +0 -3
- package/dist/components/account/index.js +0 -2
- package/dist/components/auth/index.d.ts +0 -7
- package/dist/components/auth/index.js +0 -4
- package/dist/components/contact/index.d.ts +0 -2
- package/dist/components/contact/index.js +0 -1
- package/dist/content/index.d.ts +0 -6
- package/dist/content/index.js +0 -3
- /package/dist/edit/{chrome → live}/ConfirmDialog.svelte +0 -0
- /package/dist/edit/{chrome → live}/ConfirmDialog.svelte.d.ts +0 -0
- /package/dist/edit/{chrome → live}/EditPopover.svelte +0 -0
- /package/dist/edit/{chrome → live}/EditPopover.svelte.d.ts +0 -0
- /package/dist/edit/{chrome → live}/PropertyRow.svelte.d.ts +0 -0
- /package/dist/edit/{commit.svelte.js → live/commit.svelte.js} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import IconButton from '../../components/ui/IconButton.svelte';
|
|
3
|
+
import { getUiConfig } from '../../config/context.js';
|
|
4
|
+
import { getEditAdapter } from '../context.js';
|
|
5
|
+
import type { EditFrameProps } from '../chrome-props.js';
|
|
6
|
+
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
7
|
+
import EditPopover from './EditPopover.svelte';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The page-builder wrapper: a corner toolbar (revealed on hover AND
|
|
11
|
+
* :focus-within — never hover-only) with a gear that opens the property
|
|
12
|
+
* panel and a trash that confirms, then applies the remove op.
|
|
13
|
+
*
|
|
14
|
+
* Triple-gated per affordance: spec present ∧ adapter editing ∧ the
|
|
15
|
+
* capability method present. With nothing to offer it renders the children
|
|
16
|
+
* ALONE — zero wrapper element, so an unadapted app's DOM is byte-identical
|
|
17
|
+
* (pinned by test). Components mount it INSIDE their own root element, so
|
|
18
|
+
* the wrapper never disturbs the parent's flex/grid.
|
|
19
|
+
*/
|
|
20
|
+
let { spec, panel, children }: EditFrameProps = $props();
|
|
21
|
+
|
|
22
|
+
const adapter = getEditAdapter();
|
|
23
|
+
const config = getUiConfig();
|
|
24
|
+
|
|
25
|
+
const editing = $derived(spec !== undefined && (adapter?.isEditing ?? false));
|
|
26
|
+
const showGear = $derived(
|
|
27
|
+
editing && spec?.hasPanel === true && panel !== undefined && adapter?.saveProperty !== undefined
|
|
28
|
+
);
|
|
29
|
+
const showTrash = $derived(
|
|
30
|
+
editing && spec?.removeOp !== undefined && adapter?.applyOp !== undefined
|
|
31
|
+
);
|
|
32
|
+
const framed = $derived(showGear || showTrash);
|
|
33
|
+
|
|
34
|
+
let panelOpen = $state(false);
|
|
35
|
+
let confirming = $state(false);
|
|
36
|
+
let removing = $state(false);
|
|
37
|
+
let announcement = $state('');
|
|
38
|
+
let toolbar: HTMLDivElement | undefined = $state();
|
|
39
|
+
|
|
40
|
+
function closePanel(): void {
|
|
41
|
+
panelOpen = false;
|
|
42
|
+
// The popover owned focus; hand it back to the control that opened it.
|
|
43
|
+
toolbar?.querySelector('button')?.focus();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function remove(): Promise<void> {
|
|
47
|
+
if (!adapter?.applyOp || !spec?.removeOp) return;
|
|
48
|
+
removing = true;
|
|
49
|
+
try {
|
|
50
|
+
await adapter.applyOp(spec.removeOp);
|
|
51
|
+
// The host's refresh unmounts this frame with the removed item.
|
|
52
|
+
confirming = false;
|
|
53
|
+
} catch {
|
|
54
|
+
confirming = false;
|
|
55
|
+
announcement = config.editMessages.edit_saveError();
|
|
56
|
+
} finally {
|
|
57
|
+
removing = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if framed && spec}
|
|
63
|
+
<div class="vit-edit-frame">
|
|
64
|
+
{@render children()}
|
|
65
|
+
<div class="toolbar" bind:this={toolbar}>
|
|
66
|
+
{#if showGear}
|
|
67
|
+
<IconButton
|
|
68
|
+
icon="gear"
|
|
69
|
+
label={config.editMessages.edit_properties({ label: spec.label })}
|
|
70
|
+
onclick={() => (panelOpen = !panelOpen)}
|
|
71
|
+
/>
|
|
72
|
+
{/if}
|
|
73
|
+
{#if showTrash}
|
|
74
|
+
<IconButton
|
|
75
|
+
icon="trash"
|
|
76
|
+
label={config.editMessages.edit_remove()}
|
|
77
|
+
onclick={() => (confirming = true)}
|
|
78
|
+
/>
|
|
79
|
+
{/if}
|
|
80
|
+
</div>
|
|
81
|
+
{#if panelOpen && panel}
|
|
82
|
+
<EditPopover
|
|
83
|
+
label={config.editMessages.edit_properties({ label: spec.label })}
|
|
84
|
+
onclose={closePanel}
|
|
85
|
+
>
|
|
86
|
+
{@render panel()}
|
|
87
|
+
</EditPopover>
|
|
88
|
+
{/if}
|
|
89
|
+
{#if showTrash}
|
|
90
|
+
<ConfirmDialog
|
|
91
|
+
open={confirming}
|
|
92
|
+
title={config.editMessages.edit_remove()}
|
|
93
|
+
message={config.editMessages.edit_removeConfirm({ label: spec.label })}
|
|
94
|
+
confirmLabel={config.editMessages.edit_remove()}
|
|
95
|
+
pending={removing}
|
|
96
|
+
onconfirm={() => void remove()}
|
|
97
|
+
oncancel={() => (confirming = false)}
|
|
98
|
+
/>
|
|
99
|
+
{/if}
|
|
100
|
+
<span class="status" role="status">{announcement}</span>
|
|
101
|
+
</div>
|
|
102
|
+
{:else}
|
|
103
|
+
{@render children()}
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
.vit-edit-frame {
|
|
108
|
+
position: relative;
|
|
109
|
+
border-radius: var(--radius);
|
|
110
|
+
outline: 1px dashed transparent;
|
|
111
|
+
outline-offset: 4px;
|
|
112
|
+
transition: outline-color var(--transition-fast);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.vit-edit-frame:hover,
|
|
116
|
+
.vit-edit-frame:focus-within {
|
|
117
|
+
outline-color: var(--color-brand);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.toolbar {
|
|
121
|
+
position: absolute;
|
|
122
|
+
top: calc(-1 * var(--space-2));
|
|
123
|
+
right: 0;
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 2px;
|
|
126
|
+
z-index: var(--z-raised);
|
|
127
|
+
background: var(--color-surface);
|
|
128
|
+
border: 1px solid var(--color-hairline);
|
|
129
|
+
border-radius: var(--radius);
|
|
130
|
+
box-shadow: var(--shadow-1);
|
|
131
|
+
opacity: 0;
|
|
132
|
+
pointer-events: none;
|
|
133
|
+
transition: opacity var(--transition-fast);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.vit-edit-frame:hover .toolbar,
|
|
137
|
+
.vit-edit-frame:focus-within .toolbar {
|
|
138
|
+
opacity: 1;
|
|
139
|
+
pointer-events: auto;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.status {
|
|
143
|
+
position: absolute;
|
|
144
|
+
width: 1px;
|
|
145
|
+
height: 1px;
|
|
146
|
+
padding: 0;
|
|
147
|
+
margin: -1px;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
clip-path: inset(50%);
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
border: 0;
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { EditPanelProps } from '../chrome-props.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
|
+
let { rows }: EditPanelProps = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="panel">
|
|
14
|
+
{#each rows as row (JSON.stringify(row.descriptor.ref) + row.descriptor.label)}
|
|
15
|
+
<PropertyRow descriptor={row.descriptor} value={row.value} />
|
|
16
|
+
{/each}
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.panel {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: var(--space-3);
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import { getEditAdapter } from '../context.js';
|
|
4
|
+
import { commitState } from './commit.svelte.js';
|
|
5
|
+
import type { EditableAttrs, EditableProps } from '../chrome-props.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The inline-edit primitive. It renders no element of its own: the child
|
|
9
|
+
* snippet receives the text and a bag of attributes, and spreads both onto
|
|
10
|
+
* whatever semantic element the call site owns —
|
|
11
|
+
*
|
|
12
|
+
* <Editable edit={descriptor} value={title}>
|
|
13
|
+
* {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
|
|
14
|
+
* </Editable>
|
|
15
|
+
*
|
|
16
|
+
* — the same contract Field uses for its control attrs, and for the same
|
|
17
|
+
* reason: the element (h1, p, span) is part of the document outline and
|
|
18
|
+
* belongs to the caller, not behind a prop.
|
|
19
|
+
*
|
|
20
|
+
* Inactive — no `edit` descriptor, no adapter in context (a read-only
|
|
21
|
+
* app), or the adapter's `isEditing` off — it renders the child with an
|
|
22
|
+
* empty attribute bag: no listeners, no wrapper, no cost.
|
|
23
|
+
*
|
|
24
|
+
* Active, it makes the element a plain-text `contenteditable` textbox.
|
|
25
|
+
* The DOM owns the draft while the reader types; this component reads it
|
|
26
|
+
* back on commit. Enter commits single-line fields (Cmd/Ctrl+Enter for
|
|
27
|
+
* 'multiline'), Escape reverts to the last saved value, blur commits a
|
|
28
|
+
* dirty draft. A failed save keeps the draft on screen in the error state
|
|
29
|
+
* so nothing typed is lost. State is announced through a visually hidden
|
|
30
|
+
* `role="status"` region, the pattern ShareRow set for its copy action.
|
|
31
|
+
*
|
|
32
|
+
* Styling for the affordance lives in base.css (`[data-vit-editing]`):
|
|
33
|
+
* the element is rendered by the *caller's* markup, which scoped styles
|
|
34
|
+
* here could never reach.
|
|
35
|
+
*/
|
|
36
|
+
let { edit, value, children }: EditableProps = $props();
|
|
37
|
+
|
|
38
|
+
const adapter = getEditAdapter();
|
|
39
|
+
const config = getUiConfig();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The commit lifecycle — the four states, the announcements, the last
|
|
43
|
+
* persisted value and the follow-the-prop rule — is shared with
|
|
44
|
+
* `PropertyRow` (see `./commit.svelte.js`). What stays here is where the
|
|
45
|
+
* DRAFT lives: in the DOM, because this is a contenteditable.
|
|
46
|
+
*/
|
|
47
|
+
// svelte-ignore state_referenced_locally
|
|
48
|
+
const commit_ = commitState(value, config.editMessages);
|
|
49
|
+
/** What the child snippet renders. Only rewritten while the DOM is not
|
|
50
|
+
* being typed in — Svelte and the reader must not fight over the node. */
|
|
51
|
+
// svelte-ignore state_referenced_locally
|
|
52
|
+
let renderText = $state(value);
|
|
53
|
+
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const adopted = commit_.follow(value);
|
|
56
|
+
if (adopted !== null) renderText = adopted;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const active = $derived(edit !== undefined && (adapter?.isEditing ?? false));
|
|
60
|
+
const multiline = $derived(edit?.format === 'multiline' || edit?.format === 'richtext');
|
|
61
|
+
|
|
62
|
+
/** contenteditable text, innerText keeps line breaks; NBSPs become spaces. */
|
|
63
|
+
function textOf(element: HTMLElement): string {
|
|
64
|
+
return (element.innerText ?? element.textContent ?? '').replace(/\u00a0/g, ' ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleBeforeInput(event: InputEvent): void {
|
|
68
|
+
// 'plaintext-only' is not universal (Firefox); strip rich insertions.
|
|
69
|
+
if (event.inputType === 'insertFromPaste' || event.inputType === 'insertFromDrop') return;
|
|
70
|
+
if (!multiline && event.inputType === 'insertParagraph') event.preventDefault();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handlePaste(event: ClipboardEvent): void {
|
|
74
|
+
// Plain text only, whatever the clipboard holds.
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
77
|
+
const selection = window.getSelection();
|
|
78
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
79
|
+
const range = selection.getRangeAt(0);
|
|
80
|
+
range.deleteContents();
|
|
81
|
+
range.insertNode(document.createTextNode(multiline ? text : text.replace(/\s*\n\s*/g, ' ')));
|
|
82
|
+
selection.collapseToEnd();
|
|
83
|
+
commit_.markDirty();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleInput(): void {
|
|
87
|
+
commit_.markDirty();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function commit(element: HTMLElement): Promise<void> {
|
|
91
|
+
if (!edit || !adapter) return;
|
|
92
|
+
const draft = textOf(element).trim();
|
|
93
|
+
if (draft === commit_.saved) {
|
|
94
|
+
commit_.settle();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// A failed save leaves the draft in the DOM; the reader decides whether
|
|
98
|
+
// to retry.
|
|
99
|
+
await commit_.commit(draft, () => adapter.save(edit, draft));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleBlur(event: FocusEvent): void {
|
|
103
|
+
const status = commit_.status;
|
|
104
|
+
if (status === 'dirty' || status === 'error') void commit(event.currentTarget as HTMLElement);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
108
|
+
const element = event.currentTarget as HTMLElement;
|
|
109
|
+
if (event.key === 'Escape') {
|
|
110
|
+
// Svelte's cached text still equals renderText, so a state write
|
|
111
|
+
// alone cannot repaint a node the reader has mutated: restore the
|
|
112
|
+
// DOM directly, then settle.
|
|
113
|
+
element.textContent = commit_.saved;
|
|
114
|
+
commit_.revert();
|
|
115
|
+
element.blur();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const commitKey = multiline
|
|
119
|
+
? event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
|
120
|
+
: event.key === 'Enter';
|
|
121
|
+
if (commitKey) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
void commit(element);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const attrs = $derived<EditableAttrs>(
|
|
128
|
+
active
|
|
129
|
+
? {
|
|
130
|
+
contenteditable: 'plaintext-only',
|
|
131
|
+
role: 'textbox',
|
|
132
|
+
'aria-label': edit?.label,
|
|
133
|
+
'aria-multiline': multiline ? 'true' : undefined,
|
|
134
|
+
'data-vit-editing': commit_.status,
|
|
135
|
+
onbeforeinput: handleBeforeInput,
|
|
136
|
+
oninput: handleInput,
|
|
137
|
+
onblur: handleBlur,
|
|
138
|
+
onkeydown: handleKeydown,
|
|
139
|
+
onpaste: handlePaste
|
|
140
|
+
}
|
|
141
|
+
: {}
|
|
142
|
+
);
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
{@render children(renderText, attrs)}
|
|
146
|
+
{#if active}
|
|
147
|
+
<span class="status" role="status">{commit_.announcement}</span>
|
|
148
|
+
{/if}
|
|
149
|
+
|
|
150
|
+
<style>
|
|
151
|
+
.status {
|
|
152
|
+
position: absolute;
|
|
153
|
+
width: 1px;
|
|
154
|
+
height: 1px;
|
|
155
|
+
padding: 0;
|
|
156
|
+
margin: -1px;
|
|
157
|
+
overflow: hidden;
|
|
158
|
+
clip-path: inset(50%);
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
border: 0;
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from '../../components/ui/Button.svelte';
|
|
3
|
+
import Icon from '../../components/ui/Icon.svelte';
|
|
4
|
+
import Modal from '../../components/ui/Modal.svelte';
|
|
5
|
+
import { getUiConfig } from '../../config/context.js';
|
|
6
|
+
import type { ParameterlessKey } from '../../config/types.js';
|
|
7
|
+
import { getEditAdapter } from '../context.js';
|
|
8
|
+
import { propertyValue } from './commit.svelte.js';
|
|
9
|
+
import type { LinkEditProps } from '../chrome-props.js';
|
|
10
|
+
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A LINK edited whole: one modal for its text and its destination, instead
|
|
14
|
+
* of an inline label swap plus a separate property panel — a link's two
|
|
15
|
+
* halves belong to one gesture.
|
|
16
|
+
*
|
|
17
|
+
* While the adapter is editing and a text descriptor is supplied, the
|
|
18
|
+
* control is REPLACED by a button wearing the label (the ActionLabel rule:
|
|
19
|
+
* a caret or a click inside a live link would navigate mid-edit) that
|
|
20
|
+
* opens the modal. Desa commits ONLY the halves that changed — the text
|
|
21
|
+
* through `adapter.save`, the destination through `adapter.saveProperty` —
|
|
22
|
+
* so a failure on one keeps the other's success and the modal stays open
|
|
23
|
+
* with the reason. Extra rows (a menu link's Ordre) and a confirmed
|
|
24
|
+
* Elimina ride in the same modal when the host supplies them. Without an
|
|
25
|
+
* adapter or a descriptor the control renders untouched, byte-identical to
|
|
26
|
+
* a read-only build.
|
|
27
|
+
*/
|
|
28
|
+
let {
|
|
29
|
+
text,
|
|
30
|
+
href,
|
|
31
|
+
extras = undefined,
|
|
32
|
+
removeOp = undefined,
|
|
33
|
+
label = undefined,
|
|
34
|
+
control
|
|
35
|
+
}: LinkEditProps = $props();
|
|
36
|
+
|
|
37
|
+
const adapter = getEditAdapter();
|
|
38
|
+
const config = getUiConfig();
|
|
39
|
+
const messages = $derived(config.editMessages);
|
|
40
|
+
|
|
41
|
+
const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
|
|
42
|
+
// Every field beyond the text needs both a place and a verb to save with.
|
|
43
|
+
const editsHref = $derived(href.descriptor !== undefined && adapter?.saveProperty !== undefined);
|
|
44
|
+
const editableExtras = $derived(
|
|
45
|
+
adapter?.saveProperty !== undefined
|
|
46
|
+
? (extras ?? []).filter((extra) => extra.descriptor.type !== 'image')
|
|
47
|
+
: []
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** A flag state's wording, from the same catalog the site renders. */
|
|
51
|
+
const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
|
|
52
|
+
const removable = $derived(removeOp !== undefined && adapter?.applyOp !== undefined);
|
|
53
|
+
|
|
54
|
+
let open = $state(false);
|
|
55
|
+
let draftText = $state('');
|
|
56
|
+
let draftHref = $state('');
|
|
57
|
+
let draftExtras = $state<string[]>([]);
|
|
58
|
+
let saving = $state(false);
|
|
59
|
+
let confirmingRemove = $state(false);
|
|
60
|
+
let removing = $state(false);
|
|
61
|
+
let error = $state<string | null>(null);
|
|
62
|
+
|
|
63
|
+
function show(): void {
|
|
64
|
+
draftText = text.value;
|
|
65
|
+
draftHref = href.value;
|
|
66
|
+
draftExtras = editableExtras.map((extra) => extra.value ?? '');
|
|
67
|
+
error = null;
|
|
68
|
+
open = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Escape/Cancel·la/backdrop: discard the drafts, keep the stored values. */
|
|
72
|
+
function cancel(): void {
|
|
73
|
+
if (saving || removing) return;
|
|
74
|
+
open = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function save(): Promise<void> {
|
|
78
|
+
if (!adapter || text.edit === undefined) return;
|
|
79
|
+
const nextText = draftText.trim();
|
|
80
|
+
const nextHref = draftHref.trim();
|
|
81
|
+
saving = true;
|
|
82
|
+
error = null;
|
|
83
|
+
try {
|
|
84
|
+
// Sequential on purpose: if the text lands and the address fails, the
|
|
85
|
+
// reseeded draft no longer differs, so a retry commits only what is
|
|
86
|
+
// still pending.
|
|
87
|
+
if (nextText !== text.value) {
|
|
88
|
+
await adapter.save(text.edit, nextText);
|
|
89
|
+
draftText = nextText;
|
|
90
|
+
}
|
|
91
|
+
if (editsHref && href.descriptor && nextHref !== href.value) {
|
|
92
|
+
await adapter.saveProperty!(href.descriptor, nextHref);
|
|
93
|
+
draftHref = nextHref;
|
|
94
|
+
}
|
|
95
|
+
for (const [index, extra] of editableExtras.entries()) {
|
|
96
|
+
const next = draftExtras[index].trim();
|
|
97
|
+
if (next === (extra.value ?? '')) continue;
|
|
98
|
+
// A flag extra hands the adapter a BOOLEAN, like a panel row does.
|
|
99
|
+
await adapter.saveProperty!(extra.descriptor, propertyValue(extra.descriptor, next));
|
|
100
|
+
draftExtras[index] = next;
|
|
101
|
+
}
|
|
102
|
+
open = false;
|
|
103
|
+
} catch (thrown) {
|
|
104
|
+
error =
|
|
105
|
+
thrown instanceof Error && thrown.message ? thrown.message : messages.edit_saveError();
|
|
106
|
+
} finally {
|
|
107
|
+
saving = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Confirmed removal closes everything; a failure reports in THIS modal. */
|
|
112
|
+
async function remove(): Promise<void> {
|
|
113
|
+
if (!adapter?.applyOp || removeOp === undefined) return;
|
|
114
|
+
removing = true;
|
|
115
|
+
error = null;
|
|
116
|
+
try {
|
|
117
|
+
await adapter.applyOp(removeOp);
|
|
118
|
+
confirmingRemove = false;
|
|
119
|
+
open = false;
|
|
120
|
+
} catch (thrown) {
|
|
121
|
+
confirmingRemove = false;
|
|
122
|
+
error =
|
|
123
|
+
thrown instanceof Error && thrown.message ? thrown.message : messages.edit_saveError();
|
|
124
|
+
} finally {
|
|
125
|
+
removing = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
{#if editing}
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="link-swap"
|
|
134
|
+
data-vit-editing="idle"
|
|
135
|
+
aria-label={messages.edit_editLink({ label: label ?? text.value })}
|
|
136
|
+
onclick={show}
|
|
137
|
+
>
|
|
138
|
+
{text.value}
|
|
139
|
+
<Icon name="pencil" size={13} />
|
|
140
|
+
</button>
|
|
141
|
+
<Modal
|
|
142
|
+
{open}
|
|
143
|
+
title={messages.edit_editLink({ label: label ?? text.value })}
|
|
144
|
+
onclose={cancel}
|
|
145
|
+
closeLabel={messages.edit_close()}
|
|
146
|
+
>
|
|
147
|
+
<div class="fields">
|
|
148
|
+
<label>
|
|
149
|
+
<span>{messages.edit_linkText()}</span>
|
|
150
|
+
<input type="text" bind:value={draftText} disabled={saving} />
|
|
151
|
+
</label>
|
|
152
|
+
{#if editsHref}
|
|
153
|
+
<label>
|
|
154
|
+
<span>{messages.edit_linkUrl()}</span>
|
|
155
|
+
<!-- type=text, not url: site destinations are relative paths. -->
|
|
156
|
+
<input type="text" bind:value={draftHref} disabled={saving} />
|
|
157
|
+
</label>
|
|
158
|
+
{/if}
|
|
159
|
+
{#each editableExtras as extra, index (index)}
|
|
160
|
+
<label>
|
|
161
|
+
<span>{extra.descriptor.label}</span>
|
|
162
|
+
{#if extra.descriptor.type === 'select'}
|
|
163
|
+
<select bind:value={draftExtras[index]} disabled={saving}>
|
|
164
|
+
{#each extra.descriptor.options ?? [] as option (option.value)}
|
|
165
|
+
<option value={option.value}>{option.label}</option>
|
|
166
|
+
{/each}
|
|
167
|
+
</select>
|
|
168
|
+
{:else if extra.descriptor.type === 'flag'}
|
|
169
|
+
<select bind:value={draftExtras[index]} disabled={saving}>
|
|
170
|
+
<option value="true">{wording(extra.descriptor.on ?? 'status_published')}</option>
|
|
171
|
+
<option value="false">{wording(extra.descriptor.off ?? 'status_draft')}</option>
|
|
172
|
+
</select>
|
|
173
|
+
{:else}
|
|
174
|
+
<input
|
|
175
|
+
type={extra.descriptor.type === 'date' ? 'date' : 'text'}
|
|
176
|
+
placeholder={extra.descriptor.placeholder}
|
|
177
|
+
bind:value={draftExtras[index]}
|
|
178
|
+
disabled={saving}
|
|
179
|
+
/>
|
|
180
|
+
{/if}
|
|
181
|
+
</label>
|
|
182
|
+
{/each}
|
|
183
|
+
</div>
|
|
184
|
+
{#if error}
|
|
185
|
+
<p class="error" role="alert">{error}</p>
|
|
186
|
+
{/if}
|
|
187
|
+
<div class="actions">
|
|
188
|
+
<Button pending={saving ? 1 : 0} onclick={() => void save()}>{messages.edit_save()}</Button>
|
|
189
|
+
<Button variant="ghost" pending={null} onclick={cancel}>{messages.edit_cancel()}</Button>
|
|
190
|
+
{#if removable}
|
|
191
|
+
<span class="danger">
|
|
192
|
+
<Button variant="ghost" pending={null} onclick={() => (confirmingRemove = true)}>
|
|
193
|
+
{messages.edit_remove()}
|
|
194
|
+
</Button>
|
|
195
|
+
</span>
|
|
196
|
+
{/if}
|
|
197
|
+
</div>
|
|
198
|
+
</Modal>
|
|
199
|
+
{#if removable}
|
|
200
|
+
<ConfirmDialog
|
|
201
|
+
open={confirmingRemove}
|
|
202
|
+
title={messages.edit_remove()}
|
|
203
|
+
message={messages.edit_removeConfirm({ label: label ?? text.value })}
|
|
204
|
+
confirmLabel={messages.edit_remove()}
|
|
205
|
+
pending={removing}
|
|
206
|
+
onconfirm={() => void remove()}
|
|
207
|
+
oncancel={() => (confirmingRemove = false)}
|
|
208
|
+
/>
|
|
209
|
+
{/if}
|
|
210
|
+
{:else}
|
|
211
|
+
{@render control()}
|
|
212
|
+
{/if}
|
|
213
|
+
|
|
214
|
+
<style>
|
|
215
|
+
/* The ActionLabel footprint plus a pencil hint: the same dashed affordance
|
|
216
|
+
(base.css paints [data-vit-editing]), but a click OPENS rather than
|
|
217
|
+
focuses — this swap is a button, never a caret. */
|
|
218
|
+
.link-swap {
|
|
219
|
+
display: inline-flex;
|
|
220
|
+
align-items: center;
|
|
221
|
+
gap: var(--space-1);
|
|
222
|
+
padding: 0.2em 0.4em;
|
|
223
|
+
border: none;
|
|
224
|
+
background: transparent;
|
|
225
|
+
font: inherit;
|
|
226
|
+
color: inherit;
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.fields {
|
|
231
|
+
display: flex;
|
|
232
|
+
flex-direction: column;
|
|
233
|
+
gap: var(--space-3);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
label {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
gap: var(--space-1);
|
|
240
|
+
font-size: var(--text-sm);
|
|
241
|
+
font-weight: 600;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* PropertyRow's control look — the modal is the panel, relocated. */
|
|
245
|
+
input {
|
|
246
|
+
font: inherit;
|
|
247
|
+
font-size: var(--text-sm);
|
|
248
|
+
font-weight: 400;
|
|
249
|
+
color: var(--color-ink);
|
|
250
|
+
padding: var(--space-1) var(--space-2);
|
|
251
|
+
border: 1px solid var(--color-hairline);
|
|
252
|
+
border-radius: var(--radius);
|
|
253
|
+
background: var(--color-surface);
|
|
254
|
+
width: 100%;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.error {
|
|
258
|
+
margin: var(--space-3) 0 0;
|
|
259
|
+
color: var(--series-8);
|
|
260
|
+
font-size: var(--text-sm);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.actions {
|
|
264
|
+
display: flex;
|
|
265
|
+
gap: var(--space-2);
|
|
266
|
+
margin-top: var(--space-4);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Elimina sits apart from the commit pair, wearing the error hue — the
|
|
270
|
+
ConfirmDialog token override, reused. */
|
|
271
|
+
.danger {
|
|
272
|
+
margin-left: auto;
|
|
273
|
+
--color-brand: var(--series-8);
|
|
274
|
+
--color-ink: var(--series-8);
|
|
275
|
+
}
|
|
276
|
+
</style>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getUiConfig } from '../../config/context.js';
|
|
3
3
|
import type { ParameterlessKey } from '../../config/types.js';
|
|
4
4
|
import { getEditAdapter } from '../context.js';
|
|
5
|
-
import { commitState, propertyValue } from '
|
|
5
|
+
import { commitState, propertyValue } from './commit.svelte.js';
|
|
6
6
|
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { EditMessages } from '
|
|
2
|
-
import type { PropertyDescriptor, PropertyValue } from '
|
|
1
|
+
import type { EditMessages } from '../../config/edit-messages.js';
|
|
2
|
+
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The commit lifecycle of ONE editable value — the state machine
|
|
5
5
|
* `Editable` and `PropertyRow` both ran, in one place.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EditChrome } from '../context.js';
|
|
2
|
+
/**
|
|
3
|
+
* The package's edit chrome, as the table a host hands `setEditAdapter`.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE module outside `edit/live/` allowed to import the live
|
|
6
|
+
* components (see `live-isolation.test.ts`). A host that edits imports it —
|
|
7
|
+
* brain, at its mirror layout — and a host that only renders never names it,
|
|
8
|
+
* which is what keeps the live half out of that host's bundle.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EDIT_CHROME: EditChrome;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import AddSlot from './AddSlot.svelte';
|
|
2
|
+
import Editable from './Editable.svelte';
|
|
3
|
+
import EditFrame from './EditFrame.svelte';
|
|
4
|
+
import EditPanel from './EditPanel.svelte';
|
|
5
|
+
import LinkEdit from './LinkEdit.svelte';
|
|
6
|
+
/**
|
|
7
|
+
* The package's edit chrome, as the table a host hands `setEditAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* This is the ONE module outside `edit/live/` allowed to import the live
|
|
10
|
+
* components (see `live-isolation.test.ts`). A host that edits imports it —
|
|
11
|
+
* brain, at its mirror layout — and a host that only renders never names it,
|
|
12
|
+
* which is what keeps the live half out of that host's bundle.
|
|
13
|
+
*/
|
|
14
|
+
export const EDIT_CHROME = { Editable, EditFrame, EditPanel, AddSlot, LinkEdit };
|