@vit-foundation/ui 0.16.0 → 0.18.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/timeline/TimelineMilestone.svelte +8 -1
- package/dist/components/ui/Link.svelte +13 -1
- package/dist/components/ui/Link.svelte.d.ts +7 -0
- package/dist/content-components.d.ts +1 -0
- package/dist/edit/Editable.svelte +22 -38
- package/dist/edit/chrome/LinkEdit.svelte +36 -4
- package/dist/edit/chrome/LinkEdit.svelte.d.ts +5 -1
- package/dist/edit/chrome/PropertyRow.svelte +36 -53
- package/dist/edit/chrome/PropertyRow.svelte.d.ts +5 -3
- package/dist/edit/commit.svelte.d.ts +58 -0
- package/dist/edit/commit.svelte.js +70 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/utils/paths.d.ts +24 -0
- package/dist/utils/paths.js +28 -0
- package/package.json +3 -1
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import EditPanel from '../../edit/chrome/EditPanel.svelte';
|
|
31
31
|
import Editable from '../../edit/Editable.svelte';
|
|
32
32
|
import { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from '../../utils/milestones.js';
|
|
33
|
+
import { isExternalUrl } from '../../utils/paths.js';
|
|
33
34
|
import CardMedia from '../ui/CardMedia.svelte';
|
|
34
35
|
import DraftBadge from '../../edit/chrome/DraftBadge.svelte';
|
|
35
36
|
import DateText from '../ui/DateText.svelte';
|
|
@@ -96,7 +97,13 @@
|
|
|
96
97
|
* predicate — they never route the value through Link, so nothing splices a
|
|
97
98
|
* locale into it.
|
|
98
99
|
*/
|
|
99
|
-
|
|
100
|
+
// An ALLOW-LIST, not "does it look absolute". The predicate here used to be
|
|
101
|
+
// /^(?:[a-z][a-z0-9+.-]*:)?\/\//i — it asks for '//', so any scheme
|
|
102
|
+
// without one ('javascript:alert(1)') read as INTERNAL and went to Link,
|
|
103
|
+
// which handed it to the locale resolver and got it back unchanged. A
|
|
104
|
+
// destination that is neither a path nor an http(s) URL now renders no
|
|
105
|
+
// link at all rather than choosing between two wrong branches.
|
|
106
|
+
const isExternal = $derived(isExternalUrl(milestone.linkUrl ?? ''));
|
|
100
107
|
</script>
|
|
101
108
|
|
|
102
109
|
<article style={`--milestone-color: ${color}`}>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
4
4
|
import type { Locale } from '../../config/types.js';
|
|
5
5
|
import { getUiConfig } from '../../config/context.js';
|
|
6
|
+
import { isInternalPath } from '../../utils/paths.js';
|
|
6
7
|
|
|
7
8
|
interface Props extends HTMLAnchorAttributes {
|
|
8
9
|
/**
|
|
@@ -13,6 +14,13 @@
|
|
|
13
14
|
* path can come back mangled, and no `rel` is emitted. A content column that may hold
|
|
14
15
|
* either (a milestone's `link_url`) branches at the call site and
|
|
15
16
|
* renders a bare anchor for the external case.
|
|
17
|
+
*
|
|
18
|
+
* This is ENFORCED, not assumed. The sentence above was here while the
|
|
19
|
+
* component handed anything at all to `config.href` — and a resolver
|
|
20
|
+
* that localizes paths returns 'javascript:alert(1)' unchanged, so an
|
|
21
|
+
* editor-written destination reached an <a href> intact. Anything that
|
|
22
|
+
* is not a root-relative path renders as plain text now: dropping a
|
|
23
|
+
* link is a visible bug someone reports, emitting a hostile one is not.
|
|
16
24
|
*/
|
|
17
25
|
href: string;
|
|
18
26
|
/** Target locale; defaults to the current request/page locale. */
|
|
@@ -24,7 +32,11 @@
|
|
|
24
32
|
|
|
25
33
|
const config = getUiConfig();
|
|
26
34
|
|
|
27
|
-
const
|
|
35
|
+
const internal = $derived(isInternalPath(href));
|
|
36
|
+
const resolvedHref = $derived(internal ? config.href(href, { locale }) : undefined);
|
|
28
37
|
</script>
|
|
29
38
|
|
|
39
|
+
<!-- No href rather than no element: an anchor without one is not a link and
|
|
40
|
+
navigates nowhere, while the surrounding CSS (which selects `a`) keeps
|
|
41
|
+
applying, so a refused destination costs the layout nothing. -->
|
|
30
42
|
<a href={resolvedHref} {...rest}>{@render children()}</a>
|
|
@@ -10,6 +10,13 @@ interface Props extends HTMLAnchorAttributes {
|
|
|
10
10
|
* path can come back mangled, and no `rel` is emitted. A content column that may hold
|
|
11
11
|
* either (a milestone's `link_url`) branches at the call site and
|
|
12
12
|
* renders a bare anchor for the external case.
|
|
13
|
+
*
|
|
14
|
+
* This is ENFORCED, not assumed. The sentence above was here while the
|
|
15
|
+
* component handed anything at all to `config.href` — and a resolver
|
|
16
|
+
* that localizes paths returns 'javascript:alert(1)' unchanged, so an
|
|
17
|
+
* editor-written destination reached an <a href> intact. Anything that
|
|
18
|
+
* is not a root-relative path renders as plain text now: dropping a
|
|
19
|
+
* link is a visible bug someone reports, emitting a hostile one is not.
|
|
13
20
|
*/
|
|
14
21
|
href: string;
|
|
15
22
|
/** Target locale; defaults to the current request/page locale. */
|
|
@@ -11,6 +11,7 @@ export type { ProjectEditMap } from './components/projects/ProjectCard.svelte';
|
|
|
11
11
|
export { default as SortSelect } from './components/weeklies/SortSelect.svelte';
|
|
12
12
|
export { default as TeamMemberCard } from './components/team/TeamMemberCard.svelte';
|
|
13
13
|
export type { TeamMemberEditMap } from './components/team/TeamMemberCard.svelte';
|
|
14
|
+
export type { CollaboratorEditMap } from './components/team/CollaboratorList.svelte';
|
|
14
15
|
export { default as Timeline } from './components/timeline/Timeline.svelte';
|
|
15
16
|
export { default as TimelineMilestone } from './components/timeline/TimelineMilestone.svelte';
|
|
16
17
|
export type { MilestoneEditMap } from './components/timeline/TimelineMilestone.svelte';
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { getUiConfig } from '../config/context.js';
|
|
4
4
|
import { getEditAdapter } from './context.js';
|
|
5
|
+
import { commitState } from './commit.svelte.js';
|
|
5
6
|
import type { EditDescriptor } from './types.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -58,30 +59,22 @@
|
|
|
58
59
|
const adapter = getEditAdapter();
|
|
59
60
|
const config = getUiConfig();
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The commit lifecycle — the four states, the announcements, the last
|
|
64
|
+
* persisted value and the follow-the-prop rule — is shared with
|
|
65
|
+
* `PropertyRow` (see `./commit.svelte.js`). What stays here is where the
|
|
66
|
+
* DRAFT lives: in the DOM, because this is a contenteditable.
|
|
67
|
+
*/
|
|
63
68
|
// svelte-ignore state_referenced_locally
|
|
64
|
-
|
|
69
|
+
const commit_ = commitState(value, config.editMessages);
|
|
65
70
|
/** What the child snippet renders. Only rewritten while the DOM is not
|
|
66
71
|
* being typed in — Svelte and the reader must not fight over the node. */
|
|
67
72
|
// svelte-ignore state_referenced_locally
|
|
68
73
|
let renderText = $state(value);
|
|
69
|
-
let announcement = $state('');
|
|
70
74
|
|
|
71
|
-
// Follow the prop when the app reloads content underneath us — and only
|
|
72
|
-
// then. Diffing against savedValue instead would fire after every save,
|
|
73
|
-
// where savedValue has legitimately advanced past the prop, and repaint
|
|
74
|
-
// the committed draft with stale copy. Never over a draft being held.
|
|
75
|
-
// svelte-ignore state_referenced_locally
|
|
76
|
-
let lastPropValue = $state(value);
|
|
77
75
|
$effect(() => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (status === 'idle') {
|
|
81
|
-
savedValue = value;
|
|
82
|
-
renderText = value;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
76
|
+
const adopted = commit_.follow(value);
|
|
77
|
+
if (adopted !== null) renderText = adopted;
|
|
85
78
|
});
|
|
86
79
|
|
|
87
80
|
const active = $derived(edit !== undefined && (adapter?.isEditing ?? false));
|
|
@@ -108,35 +101,27 @@
|
|
|
108
101
|
range.deleteContents();
|
|
109
102
|
range.insertNode(document.createTextNode(multiline ? text : text.replace(/\s*\n\s*/g, ' ')));
|
|
110
103
|
selection.collapseToEnd();
|
|
111
|
-
|
|
104
|
+
commit_.markDirty();
|
|
112
105
|
}
|
|
113
106
|
|
|
114
107
|
function handleInput(): void {
|
|
115
|
-
|
|
108
|
+
commit_.markDirty();
|
|
116
109
|
}
|
|
117
110
|
|
|
118
111
|
async function commit(element: HTMLElement): Promise<void> {
|
|
119
112
|
if (!edit || !adapter) return;
|
|
120
113
|
const draft = textOf(element).trim();
|
|
121
|
-
if (draft ===
|
|
122
|
-
|
|
114
|
+
if (draft === commit_.saved) {
|
|
115
|
+
commit_.settle();
|
|
123
116
|
return;
|
|
124
117
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
await adapter.save(edit, draft);
|
|
129
|
-
savedValue = draft;
|
|
130
|
-
status = 'idle';
|
|
131
|
-
announcement = config.editMessages.edit_saved();
|
|
132
|
-
} catch {
|
|
133
|
-
// Draft stays in the DOM; the reader decides whether to retry.
|
|
134
|
-
status = 'error';
|
|
135
|
-
announcement = config.editMessages.edit_saveError();
|
|
136
|
-
}
|
|
118
|
+
// A failed save leaves the draft in the DOM; the reader decides whether
|
|
119
|
+
// to retry.
|
|
120
|
+
await commit_.commit(draft, () => adapter.save(edit, draft));
|
|
137
121
|
}
|
|
138
122
|
|
|
139
123
|
function handleBlur(event: FocusEvent): void {
|
|
124
|
+
const status = commit_.status;
|
|
140
125
|
if (status === 'dirty' || status === 'error') void commit(event.currentTarget as HTMLElement);
|
|
141
126
|
}
|
|
142
127
|
|
|
@@ -146,9 +131,8 @@
|
|
|
146
131
|
// Svelte's cached text still equals renderText, so a state write
|
|
147
132
|
// alone cannot repaint a node the reader has mutated: restore the
|
|
148
133
|
// DOM directly, then settle.
|
|
149
|
-
element.textContent =
|
|
150
|
-
|
|
151
|
-
announcement = '';
|
|
134
|
+
element.textContent = commit_.saved;
|
|
135
|
+
commit_.revert();
|
|
152
136
|
element.blur();
|
|
153
137
|
return;
|
|
154
138
|
}
|
|
@@ -168,7 +152,7 @@
|
|
|
168
152
|
role: 'textbox',
|
|
169
153
|
'aria-label': edit?.label,
|
|
170
154
|
'aria-multiline': multiline ? 'true' : undefined,
|
|
171
|
-
'data-vit-editing': status,
|
|
155
|
+
'data-vit-editing': commit_.status,
|
|
172
156
|
onbeforeinput: handleBeforeInput,
|
|
173
157
|
oninput: handleInput,
|
|
174
158
|
onblur: handleBlur,
|
|
@@ -181,7 +165,7 @@
|
|
|
181
165
|
|
|
182
166
|
{@render children(renderText, attrs)}
|
|
183
167
|
{#if active}
|
|
184
|
-
<span class="status" role="status">{announcement}</span>
|
|
168
|
+
<span class="status" role="status">{commit_.announcement}</span>
|
|
185
169
|
{/if}
|
|
186
170
|
|
|
187
171
|
<style>
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
import Icon from '../../components/ui/Icon.svelte';
|
|
5
5
|
import Modal from '../../components/ui/Modal.svelte';
|
|
6
6
|
import { getUiConfig } from '../../config/context.js';
|
|
7
|
+
import type { ParameterlessKey } from '../../config/types.js';
|
|
7
8
|
import { getEditAdapter } from '../context.js';
|
|
9
|
+
import { propertyValue } from '../commit.svelte.js';
|
|
8
10
|
import type { EditDescriptor, EntityOp, PropertyDescriptor } from '../types.js';
|
|
9
11
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
10
12
|
|
|
@@ -31,7 +33,11 @@
|
|
|
31
33
|
href: { descriptor: PropertyDescriptor | undefined; value: string };
|
|
32
34
|
/**
|
|
33
35
|
* Extra property rows under Adreça — a menu link's Ordre. Labelled from
|
|
34
|
-
* their descriptors, committed on Desa only when changed
|
|
36
|
+
* their descriptors, committed on Desa only when changed, and rendered
|
|
37
|
+
* BY TYPE: this used to be a bare text input whatever the descriptor
|
|
38
|
+
* said, which made it a second renderer that would silently miss any
|
|
39
|
+
* property type the package grew. An `image` row has no place in a link
|
|
40
|
+
* modal and is the one type refused here.
|
|
35
41
|
*/
|
|
36
42
|
extras?: { descriptor: PropertyDescriptor; value: string | null }[];
|
|
37
43
|
/** With `adapter.applyOp`, the modal gains a confirmed Elimina action. */
|
|
@@ -57,7 +63,14 @@
|
|
|
57
63
|
const editing = $derived(text.edit !== undefined && (adapter?.isEditing ?? false));
|
|
58
64
|
// Every field beyond the text needs both a place and a verb to save with.
|
|
59
65
|
const editsHref = $derived(href.descriptor !== undefined && adapter?.saveProperty !== undefined);
|
|
60
|
-
const editableExtras = $derived(
|
|
66
|
+
const editableExtras = $derived(
|
|
67
|
+
adapter?.saveProperty !== undefined
|
|
68
|
+
? (extras ?? []).filter((extra) => extra.descriptor.type !== 'image')
|
|
69
|
+
: []
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** A flag state's wording, from the same catalog the site renders. */
|
|
73
|
+
const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
|
|
61
74
|
const removable = $derived(removeOp !== undefined && adapter?.applyOp !== undefined);
|
|
62
75
|
|
|
63
76
|
let open = $state(false);
|
|
@@ -104,7 +117,8 @@
|
|
|
104
117
|
for (const [index, extra] of editableExtras.entries()) {
|
|
105
118
|
const next = draftExtras[index].trim();
|
|
106
119
|
if (next === (extra.value ?? '')) continue;
|
|
107
|
-
|
|
120
|
+
// A flag extra hands the adapter a BOOLEAN, like a panel row does.
|
|
121
|
+
await adapter.saveProperty!(extra.descriptor, propertyValue(extra.descriptor, next));
|
|
108
122
|
draftExtras[index] = next;
|
|
109
123
|
}
|
|
110
124
|
open = false;
|
|
@@ -167,7 +181,25 @@
|
|
|
167
181
|
{#each editableExtras as extra, index (index)}
|
|
168
182
|
<label>
|
|
169
183
|
<span>{extra.descriptor.label}</span>
|
|
170
|
-
|
|
184
|
+
{#if extra.descriptor.type === 'select'}
|
|
185
|
+
<select bind:value={draftExtras[index]} disabled={saving}>
|
|
186
|
+
{#each extra.descriptor.options ?? [] as option (option.value)}
|
|
187
|
+
<option value={option.value}>{option.label}</option>
|
|
188
|
+
{/each}
|
|
189
|
+
</select>
|
|
190
|
+
{:else if extra.descriptor.type === 'flag'}
|
|
191
|
+
<select bind:value={draftExtras[index]} disabled={saving}>
|
|
192
|
+
<option value="true">{wording(extra.descriptor.on ?? 'status_published')}</option>
|
|
193
|
+
<option value="false">{wording(extra.descriptor.off ?? 'status_draft')}</option>
|
|
194
|
+
</select>
|
|
195
|
+
{:else}
|
|
196
|
+
<input
|
|
197
|
+
type={extra.descriptor.type === 'date' ? 'date' : 'text'}
|
|
198
|
+
placeholder={extra.descriptor.placeholder}
|
|
199
|
+
bind:value={draftExtras[index]}
|
|
200
|
+
disabled={saving}
|
|
201
|
+
/>
|
|
202
|
+
{/if}
|
|
171
203
|
</label>
|
|
172
204
|
{/each}
|
|
173
205
|
</div>
|
|
@@ -29,7 +29,11 @@ interface Props {
|
|
|
29
29
|
};
|
|
30
30
|
/**
|
|
31
31
|
* Extra property rows under Adreça — a menu link's Ordre. Labelled from
|
|
32
|
-
* their descriptors, committed on Desa only when changed
|
|
32
|
+
* their descriptors, committed on Desa only when changed, and rendered
|
|
33
|
+
* BY TYPE: this used to be a bare text input whatever the descriptor
|
|
34
|
+
* said, which made it a second renderer that would silently miss any
|
|
35
|
+
* property type the package grew. An `image` row has no place in a link
|
|
36
|
+
* modal and is the one type refused here.
|
|
33
37
|
*/
|
|
34
38
|
extras?: {
|
|
35
39
|
descriptor: PropertyDescriptor;
|
|
@@ -2,13 +2,16 @@
|
|
|
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 '../commit.svelte.js';
|
|
5
6
|
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
9
|
-
* its own commit lifecycle — Editable's
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* its own commit lifecycle — literally Editable's now, through
|
|
11
|
+
* `../commit.svelte.js`, where it used to be a second copy of the same
|
|
12
|
+
* four states, the same follow-the-prop effect and the same three
|
|
13
|
+
* announcements. Each row commits on change/blur independently; a failed
|
|
14
|
+
* save keeps the draft in the control so nothing typed is lost.
|
|
12
15
|
*
|
|
13
16
|
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
14
17
|
* renders when the frame's triple gate already established both.
|
|
@@ -34,61 +37,40 @@
|
|
|
34
37
|
/** A flag state's wording, from the same catalog the site renders. */
|
|
35
38
|
const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
|
|
36
39
|
|
|
37
|
-
let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
|
|
38
|
-
let announcement = $state('');
|
|
39
|
-
/** Last persisted value, what a failed draft is measured against. */
|
|
40
40
|
// svelte-ignore state_referenced_locally
|
|
41
|
-
|
|
41
|
+
const commit_ = commitState(asText(value), config.editMessages);
|
|
42
42
|
// svelte-ignore state_referenced_locally
|
|
43
43
|
let draft = $state(asText(value));
|
|
44
44
|
let fileInput: HTMLInputElement | undefined = $state();
|
|
45
45
|
|
|
46
|
-
// Follow the prop only while idle — never repaint a held draft (the
|
|
47
|
-
// Editable rule).
|
|
48
|
-
// svelte-ignore state_referenced_locally
|
|
49
|
-
let lastPropValue = $state(asText(value));
|
|
50
46
|
$effect(() => {
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
53
|
-
lastPropValue = next;
|
|
54
|
-
if (status === 'idle') {
|
|
55
|
-
savedValue = next;
|
|
56
|
-
draft = next;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
47
|
+
const adopted = commit_.follow(asText(value));
|
|
48
|
+
if (adopted !== null) draft = adopted;
|
|
59
49
|
});
|
|
60
50
|
|
|
61
51
|
async function persist(next: PropertyValue): Promise<void> {
|
|
62
52
|
if (!adapter?.saveProperty) return;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
status = 'idle';
|
|
70
|
-
announcement = config.editMessages.edit_saved();
|
|
71
|
-
} catch {
|
|
72
|
-
// Draft stays in the control; the editor decides whether to retry.
|
|
73
|
-
status = 'error';
|
|
74
|
-
announcement = config.editMessages.edit_saveError();
|
|
75
|
-
}
|
|
53
|
+
const landed = await commit_.commit(asText(next), () =>
|
|
54
|
+
adapter.saveProperty!(descriptor, next)
|
|
55
|
+
);
|
|
56
|
+
// Draft stays in the control on failure; the editor decides whether to
|
|
57
|
+
// retry. On success it takes the value the adapter actually stored.
|
|
58
|
+
if (landed) draft = asText(next);
|
|
76
59
|
}
|
|
77
60
|
|
|
78
61
|
function commit(): void {
|
|
79
62
|
const trimmed = draft.trim();
|
|
80
|
-
if (trimmed ===
|
|
81
|
-
|
|
63
|
+
if (trimmed === commit_.saved) {
|
|
64
|
+
commit_.settle();
|
|
82
65
|
return;
|
|
83
66
|
}
|
|
84
67
|
if (descriptor.type === 'flag') {
|
|
85
|
-
void persist(trimmed
|
|
68
|
+
void persist(propertyValue(descriptor, trimmed));
|
|
86
69
|
return;
|
|
87
70
|
}
|
|
88
71
|
if (trimmed === '') {
|
|
89
72
|
if (!descriptor.nullable) {
|
|
90
|
-
|
|
91
|
-
announcement = config.editMessages.edit_emptyRequired();
|
|
73
|
+
commit_.refuse(config.editMessages.edit_emptyRequired());
|
|
92
74
|
return;
|
|
93
75
|
}
|
|
94
76
|
void persist(null);
|
|
@@ -98,20 +80,21 @@
|
|
|
98
80
|
}
|
|
99
81
|
|
|
100
82
|
function markDirty(): void {
|
|
101
|
-
|
|
83
|
+
commit_.markDirty();
|
|
102
84
|
}
|
|
103
85
|
|
|
104
86
|
async function upload(event: Event): Promise<void> {
|
|
105
87
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
106
88
|
if (!file || !adapter?.uploadImage) return;
|
|
107
|
-
status = 'saving';
|
|
108
|
-
announcement = config.editMessages.edit_saving();
|
|
109
89
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
90
|
+
// The upload and the write are ONE gesture to the editor, so the
|
|
91
|
+
// upload runs inside the same commit: a failed upload reports as a
|
|
92
|
+
// failed save, which is what it is from the panel's side.
|
|
93
|
+
await commit_.commit(asText(value), async () => {
|
|
94
|
+
const path = await adapter.uploadImage!(descriptor, file);
|
|
95
|
+
await adapter.saveProperty!(descriptor, path);
|
|
96
|
+
draft = asText(path);
|
|
97
|
+
});
|
|
115
98
|
} finally {
|
|
116
99
|
if (fileInput) fileInput.value = '';
|
|
117
100
|
}
|
|
@@ -127,7 +110,7 @@
|
|
|
127
110
|
{#if descriptor.type === 'select'}
|
|
128
111
|
<select
|
|
129
112
|
{id}
|
|
130
|
-
data-vit-editing={status}
|
|
113
|
+
data-vit-editing={commit_.status}
|
|
131
114
|
value={draft}
|
|
132
115
|
onchange={(event) => {
|
|
133
116
|
draft = event.currentTarget.value;
|
|
@@ -143,7 +126,7 @@
|
|
|
143
126
|
(published/draft, open/closed), the adapter receives a boolean. -->
|
|
144
127
|
<select
|
|
145
128
|
{id}
|
|
146
|
-
data-vit-editing={status}
|
|
129
|
+
data-vit-editing={commit_.status}
|
|
147
130
|
value={draft}
|
|
148
131
|
onchange={(event) => {
|
|
149
132
|
draft = event.currentTarget.value;
|
|
@@ -154,13 +137,13 @@
|
|
|
154
137
|
<option value="false">{wording(descriptor.off ?? 'status_draft')}</option>
|
|
155
138
|
</select>
|
|
156
139
|
{:else if descriptor.type === 'image'}
|
|
157
|
-
{#if
|
|
158
|
-
<img class="thumb" src={
|
|
140
|
+
{#if commit_.saved}
|
|
141
|
+
<img class="thumb" src={commit_.saved} alt="" />
|
|
159
142
|
{/if}
|
|
160
143
|
<input
|
|
161
144
|
{id}
|
|
162
145
|
type="text"
|
|
163
|
-
data-vit-editing={status}
|
|
146
|
+
data-vit-editing={commit_.status}
|
|
164
147
|
placeholder={descriptor.placeholder}
|
|
165
148
|
bind:value={draft}
|
|
166
149
|
oninput={markDirty}
|
|
@@ -183,7 +166,7 @@
|
|
|
183
166
|
<input
|
|
184
167
|
{id}
|
|
185
168
|
type={descriptor.type === 'date' ? 'date' : descriptor.type === 'url' ? 'url' : 'text'}
|
|
186
|
-
data-vit-editing={status}
|
|
169
|
+
data-vit-editing={commit_.status}
|
|
187
170
|
placeholder={descriptor.placeholder}
|
|
188
171
|
bind:value={draft}
|
|
189
172
|
oninput={markDirty}
|
|
@@ -191,7 +174,7 @@
|
|
|
191
174
|
/>
|
|
192
175
|
{/if}
|
|
193
176
|
|
|
194
|
-
{#if descriptor.nullable &&
|
|
177
|
+
{#if descriptor.nullable && commit_.saved !== ''}
|
|
195
178
|
<button
|
|
196
179
|
type="button"
|
|
197
180
|
class="aux"
|
|
@@ -204,7 +187,7 @@
|
|
|
204
187
|
</button>
|
|
205
188
|
{/if}
|
|
206
189
|
|
|
207
|
-
<span class="status" role="status">{announcement}</span>
|
|
190
|
+
<span class="status" role="status">{commit_.announcement}</span>
|
|
208
191
|
</div>
|
|
209
192
|
|
|
210
193
|
<style>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
4
|
-
* its own commit lifecycle — Editable's
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* its own commit lifecycle — literally Editable's now, through
|
|
5
|
+
* `../commit.svelte.js`, where it used to be a second copy of the same
|
|
6
|
+
* four states, the same follow-the-prop effect and the same three
|
|
7
|
+
* announcements. Each row commits on change/blur independently; a failed
|
|
8
|
+
* save keeps the draft in the control so nothing typed is lost.
|
|
7
9
|
*
|
|
8
10
|
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
9
11
|
* renders when the frame's triple gate already established both.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EditMessages } from '../config/edit-messages.js';
|
|
2
|
+
import type { PropertyDescriptor, PropertyValue } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The commit lifecycle of ONE editable value — the state machine
|
|
5
|
+
* `Editable` and `PropertyRow` both ran, in one place.
|
|
6
|
+
*
|
|
7
|
+
* They implemented it twice: the same four states, the same "follow the prop
|
|
8
|
+
* only while idle" effect with the same `lastPropValue` trick, and the same
|
|
9
|
+
* three announcements read from the same three message keys. The inline
|
|
10
|
+
* editor's suite covered one of them, so a bug in commit-or-rollback
|
|
11
|
+
* semantics had two homes and one test surface. `LinkEdit` is deliberately
|
|
12
|
+
* NOT a caller: a modal that batch-commits several halves and reports a
|
|
13
|
+
* partial failure is a different shape, not a simpler version of this one.
|
|
14
|
+
*
|
|
15
|
+
* Where the draft LIVES stays the caller's business — the DOM owns it for a
|
|
16
|
+
* `contenteditable`, a `$state` string owns it for a panel control — which is
|
|
17
|
+
* why this holds `saved` and the status but never the draft.
|
|
18
|
+
*/
|
|
19
|
+
export type CommitStatus = 'idle' | 'dirty' | 'saving' | 'error';
|
|
20
|
+
export interface CommitState<T> {
|
|
21
|
+
/** What the affordance paints (`data-vit-editing`). */
|
|
22
|
+
readonly status: CommitStatus;
|
|
23
|
+
/** The visually hidden `role="status"` line. */
|
|
24
|
+
readonly announcement: string;
|
|
25
|
+
/** The last persisted value: what a revert restores and a commit diffs against. */
|
|
26
|
+
readonly saved: T;
|
|
27
|
+
/** Typing began. Never overrides an in-flight save. */
|
|
28
|
+
markDirty(): void;
|
|
29
|
+
/** Back to rest without a write — an unchanged draft. */
|
|
30
|
+
settle(): void;
|
|
31
|
+
/** Escape: back to rest AND silent, the announcement withdrawn with the draft. */
|
|
32
|
+
revert(): void;
|
|
33
|
+
/** Refuse before any write, with its own reason (an emptied required field). */
|
|
34
|
+
refuse(announcement: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Runs one write. Announces each phase, advances `saved` on success, and
|
|
37
|
+
* leaves the state at `'error'` on rejection so the caller's draft can stay
|
|
38
|
+
* on screen. Answers whether it landed.
|
|
39
|
+
*/
|
|
40
|
+
commit(next: T, write: () => Promise<void>): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Adopt a value the app reloaded underneath us — and only while idle.
|
|
43
|
+
* Diffing against `saved` instead would fire after every save, where
|
|
44
|
+
* `saved` has legitimately advanced past the prop, and repaint the
|
|
45
|
+
* committed draft with stale copy. Never over a draft being held.
|
|
46
|
+
*
|
|
47
|
+
* Answers the value the caller should render, or `null` for "leave it".
|
|
48
|
+
*/
|
|
49
|
+
follow(next: T): T | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function commitState<T>(initial: T, messages: EditMessages): CommitState<T>;
|
|
52
|
+
/**
|
|
53
|
+
* A control's string draft as the value the ADAPTER takes. Every control's
|
|
54
|
+
* state is text — a flag's boolean rides as `'true'`/`'false'` in its
|
|
55
|
+
* `<select>` — and this is the one place it becomes a boolean again, so the
|
|
56
|
+
* panel row and the link modal cannot disagree about the boundary.
|
|
57
|
+
*/
|
|
58
|
+
export declare function propertyValue(descriptor: PropertyDescriptor, draft: string): PropertyValue;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function commitState(initial, messages) {
|
|
2
|
+
let status = $state('idle');
|
|
3
|
+
let announcement = $state('');
|
|
4
|
+
let saved = $state(initial);
|
|
5
|
+
let lastProp = $state(initial);
|
|
6
|
+
return {
|
|
7
|
+
get status() {
|
|
8
|
+
return status;
|
|
9
|
+
},
|
|
10
|
+
get announcement() {
|
|
11
|
+
return announcement;
|
|
12
|
+
},
|
|
13
|
+
get saved() {
|
|
14
|
+
return saved;
|
|
15
|
+
},
|
|
16
|
+
markDirty() {
|
|
17
|
+
if (status !== 'saving')
|
|
18
|
+
status = 'dirty';
|
|
19
|
+
},
|
|
20
|
+
settle() {
|
|
21
|
+
status = 'idle';
|
|
22
|
+
},
|
|
23
|
+
revert() {
|
|
24
|
+
status = 'idle';
|
|
25
|
+
announcement = '';
|
|
26
|
+
},
|
|
27
|
+
refuse(next) {
|
|
28
|
+
status = 'error';
|
|
29
|
+
announcement = next;
|
|
30
|
+
},
|
|
31
|
+
async commit(next, write) {
|
|
32
|
+
status = 'saving';
|
|
33
|
+
announcement = messages.edit_saving();
|
|
34
|
+
try {
|
|
35
|
+
await write();
|
|
36
|
+
saved = next;
|
|
37
|
+
// `lastProp` is NOT advanced here: it tracks the last PROP value
|
|
38
|
+
// seen, and a save does not change the prop. Advancing it made an
|
|
39
|
+
// unchanged prop look like a reload on the next effect run, which
|
|
40
|
+
// then restored the pre-save value over the committed one.
|
|
41
|
+
status = 'idle';
|
|
42
|
+
announcement = messages.edit_saved();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
status = 'error';
|
|
47
|
+
announcement = messages.edit_saveError();
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
follow(next) {
|
|
52
|
+
if (next === lastProp)
|
|
53
|
+
return null;
|
|
54
|
+
lastProp = next;
|
|
55
|
+
if (status !== 'idle')
|
|
56
|
+
return null;
|
|
57
|
+
saved = next;
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A control's string draft as the value the ADAPTER takes. Every control's
|
|
64
|
+
* state is text — a flag's boolean rides as `'true'`/`'false'` in its
|
|
65
|
+
* `<select>` — and this is the one place it becomes a boolean again, so the
|
|
66
|
+
* panel row and the link modal cannot disagree about the boundary.
|
|
67
|
+
*/
|
|
68
|
+
export function propertyValue(descriptor, draft) {
|
|
69
|
+
return descriptor.type === 'flag' ? draft === 'true' : draft;
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,4 @@ export * from './admin.js';
|
|
|
6
6
|
export * from './forms/index.js';
|
|
7
7
|
export * from './config/index.js';
|
|
8
8
|
export * from './edit/index.js';
|
|
9
|
-
export { buildQueryString, isPathUnder } from './utils/paths.js';
|
|
9
|
+
export { buildQueryString, isExternalUrl, isInternalPath, isPathUnder } from './utils/paths.js';
|
package/dist/index.js
CHANGED
|
@@ -13,4 +13,4 @@ export * from './forms/index.js';
|
|
|
13
13
|
export * from './config/index.js';
|
|
14
14
|
export * from './edit/index.js';
|
|
15
15
|
// Path utilities shared with host apps.
|
|
16
|
-
export { buildQueryString, isPathUnder } from './utils/paths.js';
|
|
16
|
+
export { buildQueryString, isExternalUrl, isInternalPath, isPathUnder } from './utils/paths.js';
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -6,3 +6,27 @@ export declare function isPathUnder(pathname: string, prefix: string): boolean;
|
|
|
6
6
|
* mirrors and the hrefs it renders are spelled by the same function.
|
|
7
7
|
*/
|
|
8
8
|
export declare function buildQueryString(params: Record<string, string | null | undefined>): string;
|
|
9
|
+
/**
|
|
10
|
+
* Whether a destination is a ROOT-RELATIVE path — the only shape `Link` will
|
|
11
|
+
* resolve, and what "internal" means everywhere in this package.
|
|
12
|
+
*
|
|
13
|
+
* One leading slash, and the second character must not be another slash:
|
|
14
|
+
* '//evil.example' is protocol-relative and leaves the site. A bare scheme is
|
|
15
|
+
* not a path either, so 'javascript:alert(1)' fails here — which is the point.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isInternalPath(href: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a destination is an EXTERNAL URL this package is willing to emit.
|
|
20
|
+
*
|
|
21
|
+
* An allow-list, not a deny-list, and it is the reason this function exists
|
|
22
|
+
* rather than a `!isInternalPath` at each call site: the classifier it
|
|
23
|
+
* replaced accepted ANY scheme followed by '//', so a scheme WITHOUT one —
|
|
24
|
+
* 'javascript:alert(1)' — fell through to the internal branch and reached the
|
|
25
|
+
* locale resolver, which returns it verbatim into an <a href>.
|
|
26
|
+
*
|
|
27
|
+
* Protocol-relative ('//host/path') stays external on purpose: it is another
|
|
28
|
+
* origin, so localizing it would splice a locale segment into someone else's
|
|
29
|
+
* URL, and it carries the page's own scheme rather than a new one. What is
|
|
30
|
+
* excluded is every scheme that is not http(s) — which is where the harm was.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isExternalUrl(href: string): boolean;
|
package/dist/utils/paths.js
CHANGED
|
@@ -16,3 +16,31 @@ export function buildQueryString(params) {
|
|
|
16
16
|
const encoded = search.toString();
|
|
17
17
|
return encoded ? `?${encoded}` : '';
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether a destination is a ROOT-RELATIVE path — the only shape `Link` will
|
|
21
|
+
* resolve, and what "internal" means everywhere in this package.
|
|
22
|
+
*
|
|
23
|
+
* One leading slash, and the second character must not be another slash:
|
|
24
|
+
* '//evil.example' is protocol-relative and leaves the site. A bare scheme is
|
|
25
|
+
* not a path either, so 'javascript:alert(1)' fails here — which is the point.
|
|
26
|
+
*/
|
|
27
|
+
export function isInternalPath(href) {
|
|
28
|
+
return /^\/([^/]|$)/.test(href);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a destination is an EXTERNAL URL this package is willing to emit.
|
|
32
|
+
*
|
|
33
|
+
* An allow-list, not a deny-list, and it is the reason this function exists
|
|
34
|
+
* rather than a `!isInternalPath` at each call site: the classifier it
|
|
35
|
+
* replaced accepted ANY scheme followed by '//', so a scheme WITHOUT one —
|
|
36
|
+
* 'javascript:alert(1)' — fell through to the internal branch and reached the
|
|
37
|
+
* locale resolver, which returns it verbatim into an <a href>.
|
|
38
|
+
*
|
|
39
|
+
* Protocol-relative ('//host/path') stays external on purpose: it is another
|
|
40
|
+
* origin, so localizing it would splice a locale segment into someone else's
|
|
41
|
+
* URL, and it carries the page's own scheme rather than a new one. What is
|
|
42
|
+
* excluded is every scheme that is not http(s) — which is where the harm was.
|
|
43
|
+
*/
|
|
44
|
+
export function isExternalUrl(href) {
|
|
45
|
+
return /^(?:https?:)?\/\/[^/]/i.test(href);
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vit-foundation/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && npm run prepack",
|
|
7
7
|
"preview": "vite preview",
|
|
8
8
|
"prepare": "svelte-kit sync || echo ''",
|
|
9
9
|
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
10
|
+
"_comment:prepublishOnly": "Publishing is MANUAL (the release workflow has no NPM_TOKEN and fails on every tag), so the gate belongs on the command that actually publishes. npm runs this before prepack.",
|
|
11
|
+
"prepublishOnly": "npm run lint && npm run check && npm run test:unit -- --run",
|
|
10
12
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
11
13
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
12
14
|
"format": "prettier --write .",
|