@vit-foundation/ui 0.10.0 → 0.11.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/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/Modal.svelte +1 -1
- package/dist/components/ui/RichText.svelte +12 -6
- 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/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 +81 -1
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import { getEditAdapter } from '../context.js';
|
|
4
|
+
import type { PropertyDescriptor } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
8
|
+
* its own commit lifecycle — Editable's state machine, panel-shaped. Each
|
|
9
|
+
* row commits on change/blur independently; a failed save keeps the draft
|
|
10
|
+
* in the control so nothing typed is lost.
|
|
11
|
+
*
|
|
12
|
+
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
13
|
+
* renders when the frame's triple gate already established both.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
descriptor: PropertyDescriptor;
|
|
17
|
+
value: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { descriptor, value }: Props = $props();
|
|
21
|
+
|
|
22
|
+
const adapter = getEditAdapter();
|
|
23
|
+
const config = getUiConfig();
|
|
24
|
+
|
|
25
|
+
let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
|
|
26
|
+
let announcement = $state('');
|
|
27
|
+
/** Last persisted value, what a failed draft is measured against. */
|
|
28
|
+
// svelte-ignore state_referenced_locally
|
|
29
|
+
let savedValue = $state(value ?? '');
|
|
30
|
+
// svelte-ignore state_referenced_locally
|
|
31
|
+
let draft = $state(value ?? '');
|
|
32
|
+
let fileInput: HTMLInputElement | undefined = $state();
|
|
33
|
+
|
|
34
|
+
// Follow the prop only while idle — never repaint a held draft (the
|
|
35
|
+
// Editable rule).
|
|
36
|
+
// svelte-ignore state_referenced_locally
|
|
37
|
+
let lastPropValue = $state(value ?? '');
|
|
38
|
+
$effect(() => {
|
|
39
|
+
const next = value ?? '';
|
|
40
|
+
if (next !== lastPropValue) {
|
|
41
|
+
lastPropValue = next;
|
|
42
|
+
if (status === 'idle') {
|
|
43
|
+
savedValue = next;
|
|
44
|
+
draft = next;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function persist(next: string | null): Promise<void> {
|
|
50
|
+
if (!adapter?.saveProperty) return;
|
|
51
|
+
status = 'saving';
|
|
52
|
+
announcement = config.editMessages.edit_saving();
|
|
53
|
+
try {
|
|
54
|
+
await adapter.saveProperty(descriptor, next);
|
|
55
|
+
savedValue = next ?? '';
|
|
56
|
+
draft = next ?? '';
|
|
57
|
+
status = 'idle';
|
|
58
|
+
announcement = config.editMessages.edit_saved();
|
|
59
|
+
} catch {
|
|
60
|
+
// Draft stays in the control; the editor decides whether to retry.
|
|
61
|
+
status = 'error';
|
|
62
|
+
announcement = config.editMessages.edit_saveError();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commit(): void {
|
|
67
|
+
const trimmed = draft.trim();
|
|
68
|
+
if (trimmed === savedValue) {
|
|
69
|
+
status = 'idle';
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (trimmed === '') {
|
|
73
|
+
if (!descriptor.nullable) {
|
|
74
|
+
status = 'error';
|
|
75
|
+
announcement = config.editMessages.edit_emptyRequired();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
void persist(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void persist(trimmed);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function markDirty(): void {
|
|
85
|
+
if (status !== 'saving') status = 'dirty';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function upload(event: Event): Promise<void> {
|
|
89
|
+
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
90
|
+
if (!file || !adapter?.uploadImage) return;
|
|
91
|
+
status = 'saving';
|
|
92
|
+
announcement = config.editMessages.edit_saving();
|
|
93
|
+
try {
|
|
94
|
+
const path = await adapter.uploadImage(descriptor, file);
|
|
95
|
+
await persist(path);
|
|
96
|
+
} catch {
|
|
97
|
+
status = 'error';
|
|
98
|
+
announcement = config.editMessages.edit_saveError();
|
|
99
|
+
} finally {
|
|
100
|
+
if (fileInput) fileInput.value = '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One id per instance so the label reaches its control.
|
|
105
|
+
const id = $props.id();
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<div class="row">
|
|
109
|
+
<label for={id}>{descriptor.label}</label>
|
|
110
|
+
|
|
111
|
+
{#if descriptor.type === 'select'}
|
|
112
|
+
<select
|
|
113
|
+
{id}
|
|
114
|
+
data-vit-editing={status}
|
|
115
|
+
value={draft}
|
|
116
|
+
onchange={(event) => {
|
|
117
|
+
draft = event.currentTarget.value;
|
|
118
|
+
commit();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{#each descriptor.options ?? [] as option (option.value)}
|
|
122
|
+
<option value={option.value}>{option.label}</option>
|
|
123
|
+
{/each}
|
|
124
|
+
</select>
|
|
125
|
+
{:else if descriptor.type === 'image'}
|
|
126
|
+
{#if savedValue}
|
|
127
|
+
<img class="thumb" src={savedValue} alt="" />
|
|
128
|
+
{/if}
|
|
129
|
+
<input
|
|
130
|
+
{id}
|
|
131
|
+
type="text"
|
|
132
|
+
data-vit-editing={status}
|
|
133
|
+
placeholder={descriptor.placeholder}
|
|
134
|
+
bind:value={draft}
|
|
135
|
+
oninput={markDirty}
|
|
136
|
+
onchange={commit}
|
|
137
|
+
/>
|
|
138
|
+
{#if adapter?.uploadImage}
|
|
139
|
+
<input
|
|
140
|
+
bind:this={fileInput}
|
|
141
|
+
class="file"
|
|
142
|
+
type="file"
|
|
143
|
+
accept="image/*"
|
|
144
|
+
onchange={(event) => void upload(event)}
|
|
145
|
+
aria-label={config.editMessages.edit_uploadImage()}
|
|
146
|
+
/>
|
|
147
|
+
<button type="button" class="aux" onclick={() => fileInput?.click()}>
|
|
148
|
+
{config.editMessages.edit_uploadImage()}
|
|
149
|
+
</button>
|
|
150
|
+
{/if}
|
|
151
|
+
{:else}
|
|
152
|
+
<input
|
|
153
|
+
{id}
|
|
154
|
+
type={descriptor.type === 'date' ? 'date' : descriptor.type === 'url' ? 'url' : 'text'}
|
|
155
|
+
data-vit-editing={status}
|
|
156
|
+
placeholder={descriptor.placeholder}
|
|
157
|
+
bind:value={draft}
|
|
158
|
+
oninput={markDirty}
|
|
159
|
+
onchange={commit}
|
|
160
|
+
/>
|
|
161
|
+
{/if}
|
|
162
|
+
|
|
163
|
+
{#if descriptor.nullable && savedValue !== ''}
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="aux"
|
|
167
|
+
onclick={() => {
|
|
168
|
+
draft = '';
|
|
169
|
+
void persist(null);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{config.editMessages.edit_clearValue()}
|
|
173
|
+
</button>
|
|
174
|
+
{/if}
|
|
175
|
+
|
|
176
|
+
<span class="status" role="status">{announcement}</span>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<style>
|
|
180
|
+
.row {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: var(--space-1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
label {
|
|
187
|
+
font-size: var(--text-sm);
|
|
188
|
+
font-weight: 600;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
input:not(.file),
|
|
192
|
+
select {
|
|
193
|
+
font: inherit;
|
|
194
|
+
font-size: var(--text-sm);
|
|
195
|
+
padding: var(--space-1) var(--space-2);
|
|
196
|
+
border: 1px solid var(--color-hairline);
|
|
197
|
+
border-radius: var(--radius);
|
|
198
|
+
background: var(--color-surface);
|
|
199
|
+
color: var(--color-ink);
|
|
200
|
+
width: 100%;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.thumb {
|
|
204
|
+
max-width: 100%;
|
|
205
|
+
max-height: 6rem;
|
|
206
|
+
object-fit: cover;
|
|
207
|
+
border-radius: var(--radius);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* The real picker; the styled button drives it. */
|
|
211
|
+
.file {
|
|
212
|
+
position: absolute;
|
|
213
|
+
width: 1px;
|
|
214
|
+
height: 1px;
|
|
215
|
+
opacity: 0;
|
|
216
|
+
pointer-events: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.aux {
|
|
220
|
+
align-self: flex-start;
|
|
221
|
+
border: none;
|
|
222
|
+
background: none;
|
|
223
|
+
padding: 0;
|
|
224
|
+
font-size: var(--text-sm);
|
|
225
|
+
color: var(--color-brand);
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
text-decoration: underline;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.status {
|
|
231
|
+
position: absolute;
|
|
232
|
+
width: 1px;
|
|
233
|
+
height: 1px;
|
|
234
|
+
padding: 0;
|
|
235
|
+
margin: -1px;
|
|
236
|
+
overflow: hidden;
|
|
237
|
+
clip-path: inset(50%);
|
|
238
|
+
white-space: nowrap;
|
|
239
|
+
border: 0;
|
|
240
|
+
}
|
|
241
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PropertyDescriptor } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
4
|
+
* its own commit lifecycle — Editable's state machine, panel-shaped. Each
|
|
5
|
+
* row commits on change/blur independently; a failed save keeps the draft
|
|
6
|
+
* in the control so nothing typed is lost.
|
|
7
|
+
*
|
|
8
|
+
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
9
|
+
* renders when the frame's triple gate already established both.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
descriptor: PropertyDescriptor;
|
|
13
|
+
value: string | null;
|
|
14
|
+
}
|
|
15
|
+
declare const PropertyRow: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type PropertyRow = ReturnType<typeof PropertyRow>;
|
|
17
|
+
export default PropertyRow;
|
package/dist/edit/helpers.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Locale } from '../config/types.js';
|
|
2
|
-
import type { EditableEntity, EditDescriptor } from './types.js';
|
|
2
|
+
import type { CollectionRef, EditableEntity, EditDescriptor, PropertyDescriptor } from './types.js';
|
|
3
|
+
/** Everything a property descriptor carries beyond its ref. */
|
|
4
|
+
type PropertySpec = Omit<PropertyDescriptor, 'ref'>;
|
|
3
5
|
/**
|
|
4
6
|
* Descriptor for one interface-wording message (a Paraglide catalog key).
|
|
5
7
|
* Only parameterless messages are sensibly editable in place — editing the
|
|
@@ -25,3 +27,19 @@ export declare function entityEdit(entity: EditableEntity, id: string | number,
|
|
|
25
27
|
format?: EditDescriptor['format'];
|
|
26
28
|
label?: string;
|
|
27
29
|
}) => EditDescriptor;
|
|
30
|
+
/**
|
|
31
|
+
* Property-descriptor factory for one entity's scalar fields — the panel
|
|
32
|
+
* sibling of `entityEdit`, naming the row once:
|
|
33
|
+
*
|
|
34
|
+
* const property = entityProperty('milestones', milestone.id);
|
|
35
|
+
* occurredOn: property('occurred_on', { type: 'date', label: 'Data' })
|
|
36
|
+
*/
|
|
37
|
+
export declare function entityProperty(entity: EditableEntity, id: string | number): (field: string, spec: PropertySpec) => PropertyDescriptor;
|
|
38
|
+
/**
|
|
39
|
+
* A panel property over one interface-wording message — how strings that can
|
|
40
|
+
* never hold a caret (an `<option>` label, an input placeholder) still edit.
|
|
41
|
+
*/
|
|
42
|
+
export declare function chromeProperty(key: string, spec: PropertySpec): PropertyDescriptor;
|
|
43
|
+
/** Names one entity collection at one render site. */
|
|
44
|
+
export declare function collectionOf(entity: EditableEntity, scope?: string): CollectionRef;
|
|
45
|
+
export {};
|