mnfst 0.5.195 → 0.5.197

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/lib/manifest.d.ts CHANGED
@@ -331,6 +331,80 @@ export interface ManifestCamera {
331
331
  pick(opts?: Record<string, unknown>): Promise<unknown>;
332
332
  }
333
333
 
334
+ /** Element editor (`x-edit`) — opt-in plugin. Sortable lists and resizable elements
335
+ * by default; add `.authoring` where a region is a page being edited. */
336
+ export interface ManifestEdit {
337
+ /** True while a `.gated` area is editable. */
338
+ active: boolean;
339
+ canUndo: boolean;
340
+ canRedo: boolean;
341
+ /** Set a handler to route patches somewhere other than the dev server. */
342
+ onPublish: ((patches: unknown[], state: { log: unknown[]; cursor: number }) => unknown) | null;
343
+ toggle(): void;
344
+ on(): void;
345
+ off(): void;
346
+ undo(): void;
347
+ redo(): void;
348
+ /** Lock an element and its subtree out of editing (runtime, not logged). */
349
+ lock(el: Element): void;
350
+ unlock(el: Element): void;
351
+ /** Resolve the edit log into source patches and send them. */
352
+ publish(): Promise<unknown>;
353
+ /** The resolved source patches, without sending them. */
354
+ patches(): unknown[];
355
+ /** The raw delta log — persist it yourself to move edits between devices. */
356
+ export(): { log: unknown[]; cursor: number };
357
+ /** The block a context menu is acting on — set by the last right-click, or
358
+ * assignable. Block operations default to it when called with no element. */
359
+ target: Element | null;
360
+ /** Whether an operation applies right now: copy, cut, paste, duplicate, remove. */
361
+ can(op: string, el?: Element): boolean;
362
+ copy(el?: Element): boolean;
363
+ cut(el?: Element): boolean;
364
+ paste(el?: Element): boolean;
365
+ duplicate(el?: Element): boolean;
366
+ remove(el?: Element): boolean;
367
+ /** The addressable block containing a node, or null. */
368
+ block(node: Element): Element | null;
369
+ }
370
+
371
+ /** Text editor (`x-text-edit`) — the area this element sits in, or the last focused one. */
372
+ export interface ManifestTextEdit {
373
+ /** The stored value, in the mode the directive was given. Assigning replaces the
374
+ * document and resets undo history. */
375
+ value: string;
376
+ focus(): void;
377
+ /** The href at the caret; assign to set it, assign '' to unlink. */
378
+ link: string;
379
+ /** Page-level styles (font, size, leading, align, color, background) as CSS
380
+ * custom properties on the area. Presentation, not document content — persist
381
+ * it yourself. Assign `{}` to clear. */
382
+ page: Record<string, string>;
383
+ /** Select the whole document. */
384
+ selectAll(): void;
385
+ /** The current selection and its geometry, or null. Also emitted as a
386
+ * `text-edit:selection` event and as --text-edit-selection-* custom properties
387
+ * on the area, for anchoring a menu to it. */
388
+ selection: {
389
+ collapsed: boolean; text: string;
390
+ x: number; y: number; width: number; height: number;
391
+ top: number; bottom: number; left: number; right: number;
392
+ } | null;
393
+ /** Apply a command. Commands are named for the tag they produce — 'strong',
394
+ * 'blockquote', 'h2', 'ul', 'a', 'img', 'table' — plus the operations with no
395
+ * tag of their own: 'indent', 'outdent', 'align', 'color', 'background',
396
+ * 'font', 'size', 'checklist', 'clear', 'undo', 'redo', 'block'. */
397
+ run(command: string, arg?: string | number): void;
398
+ /** Whether that command is active at the caret. */
399
+ active(command: string): boolean;
400
+ /** Whether that command is available in this area's mode. */
401
+ can(command: string): boolean;
402
+ /** Read the content as markdown regardless of the storage mode. */
403
+ markdown(): string;
404
+ /** Read the content as sanitized HTML regardless of the storage mode. */
405
+ html(): string;
406
+ }
407
+
334
408
  /**
335
409
  * Manifest's custom-element/attribute authoring surface (documentation aid —
336
410
  * these are HTML attributes, not JS globals). Element styles pair with most.
@@ -340,7 +414,8 @@ export type ManifestDirective =
340
414
  | 'x-icon' | 'x-svg' | 'x-markdown' | 'x-code' | 'x-code-group'
341
415
  | 'x-toast' | 'x-tooltip' | 'x-carousel' | 'x-virtual' | 'x-resize'
342
416
  | 'x-chart' | 'x-date' | 'x-color' | 'x-colorpicker' | 'x-combobox'
343
- | 'x-export' | 'x-pay' | 'x-files' | 'x-data-files';
417
+ | 'x-export' | 'x-pay' | 'x-files' | 'x-data-files'
418
+ | 'x-edit' | 'x-text-edit';
344
419
 
345
420
  // ---------------------------------------------------------------------------
346
421
  // Global declarations (what Alpine exposes inside `x-data`, `x-show`, etc.)
@@ -381,6 +456,10 @@ declare global {
381
456
  const $chart: ManifestChart;
382
457
  /** Export plugin. */
383
458
  const $export: ManifestExport;
459
+ /** Element editor (edit plugin; opt-in). */
460
+ const $edit: ManifestEdit;
461
+ /** Text editor (text-edit plugin). */
462
+ const $text: ManifestTextEdit;
384
463
  /** Device/platform info (utilities; enriched by the native plugin). */
385
464
  const $device: ManifestDevice;
386
465
  /** Native share (native plugin). */
@@ -10,13 +10,18 @@
10
10
 
11
11
  @layer base {
12
12
 
13
+ /* Animates `scale`, not `transform`. With fill mode `both` a transform
14
+ animation keeps its final value forever, which silently overrides any
15
+ transform an author put on a popover — so a menu positioned with
16
+ translate(-50%) simply would not move. The standalone property leaves
17
+ transform free. */
13
18
  @keyframes mnfst-popover-in {
14
19
  from {
15
- transform: scale(.9)
20
+ scale: .9
16
21
  }
17
22
 
18
23
  to {
19
- transform: none
24
+ scale: 1
20
25
  }
21
26
  }
22
27
 
@@ -454,7 +454,13 @@
454
454
  const t = this.rootEl;
455
455
  if (t.matches(':popover-open')) { this.close(); return; } // toggle
456
456
  this.render();
457
- try { t.showPopover(); } catch (_) { }
457
+ // Body-appended, so neither the popover tree nor manifest.dialog.css can see
458
+ // that this calendar belongs to the field's dialog or menu. `source` names the
459
+ // invoker (a text input can't carry popovertarget), without which opening the
460
+ // calendar light-dismisses the very thing it was opened from; the attribute
461
+ // exempts it from the stray-dropdown rule that would otherwise fade it out.
462
+ t.toggleAttribute('data-popover-nested', !!(this.field && this.field.closest('[popover]')));
463
+ try { t.showPopover({ source: this.field }); } catch (_) { }
458
464
  },
459
465
  close() {
460
466
  if (this._closeTimeMenu) this._closeTimeMenu();
@@ -802,9 +808,12 @@
802
808
  segs = { hIn, mIn, apBtn };
803
809
 
804
810
  // Columns menu — body-appended popover so the calendar's overflow can't clip it,
805
- // yet nests under the in-calendar invoker (won't dismiss the calendar).
811
+ // yet nests under the in-calendar invoker (won't dismiss the calendar). Its owner
812
+ // is always a calendar, never the page, so it is never a stray dropdown for
813
+ // manifest.dialog.css to fade out.
806
814
  const menu = buildColumns(document.createElement('menu'), false);
807
815
  menu.setAttribute('popover', '');
816
+ menu.setAttribute('data-popover-nested', '');
808
817
  menu.id = 'mnfst-dp-time-' + (++_uid);
809
818
  if (state._timeMenuEl && state._timeMenuEl.isConnected) state._timeMenuEl.remove();
810
819
  document.body.appendChild(menu);
@@ -10,13 +10,18 @@
10
10
 
11
11
  @layer base {
12
12
 
13
+ /* Animates `scale`, not `transform`. With fill mode `both` a transform
14
+ animation keeps its final value forever, which silently overrides any
15
+ transform an author put on a popover — so a menu positioned with
16
+ translate(-50%) simply would not move. The standalone property leaves
17
+ transform free. */
13
18
  @keyframes mnfst-popover-in {
14
19
  from {
15
- transform: scale(.9)
20
+ scale: .9
16
21
  }
17
22
 
18
23
  to {
19
- transform: none
24
+ scale: 1
20
25
  }
21
26
  }
22
27
 
@@ -101,8 +106,11 @@
101
106
  /* Hide dropdowns when dialogs are open */
102
107
  html:has(dialog:popover-open) {
103
108
 
104
- /* Hide dropdowns outside of any dialog */
105
- & menu[popover]:not(dialog *) {
109
+ /* Hide dropdowns outside of any dialog. A generated popover (a datepicker
110
+ calendar, a colorpicker panel) is appended to <body>, so DOM position alone
111
+ can't tell page chrome from a menu the dialog itself opened —
112
+ data-popover-nested marks the latter, which CSS has no way to work out. */
113
+ & menu[popover]:not(dialog *):not([data-popover-nested]) {
106
114
  opacity: 0;
107
115
  scale: .9;
108
116
  pointer-events: none;
@@ -10,13 +10,18 @@
10
10
 
11
11
  @layer base {
12
12
 
13
+ /* Animates `scale`, not `transform`. With fill mode `both` a transform
14
+ animation keeps its final value forever, which silently overrides any
15
+ transform an author put on a popover — so a menu positioned with
16
+ translate(-50%) simply would not move. The standalone property leaves
17
+ transform free. */
13
18
  @keyframes mnfst-popover-in {
14
19
  from {
15
- transform: scale(.9)
20
+ scale: .9
16
21
  }
17
22
 
18
23
  to {
19
- transform: none
24
+ scale: 1
20
25
  }
21
26
  }
22
27
 
@@ -97,6 +97,10 @@ function initializeDropdownPlugin() {
97
97
  const uniqueDropdownId = `dropdown-${anchorCode}`;
98
98
  menu.setAttribute('id', uniqueDropdownId);
99
99
  document.body.appendChild(menu);
100
+ // Appending to <body> loses the fact that the trigger sits inside a
101
+ // dialog, which is how manifest.dialog.css tells a dialog's own menus
102
+ // from the page's. An authored menu keeps its place and needs no mark.
103
+ if (el.closest('[popover]')) menu.setAttribute('data-popover-nested', '');
100
104
  if (!modifiers.includes('context')) el.setAttribute('popovertarget', uniqueDropdownId);
101
105
 
102
106
  // Initialize Alpine on the cloned menu
@@ -127,6 +131,7 @@ function initializeDropdownPlugin() {
127
131
  menu = menuElement.cloneNode(true);
128
132
  menu.setAttribute('id', dropdownId);
129
133
  document.body.appendChild(menu);
134
+ if (el.closest('[popover]')) menu.setAttribute('data-popover-nested', '');
130
135
  if (!modifiers.includes('context')) el.setAttribute('popovertarget', dropdownId);
131
136
 
132
137
  // Initialize Alpine on the menu
@@ -220,7 +225,10 @@ function initializeDropdownPlugin() {
220
225
  clearTimeout(autoCloseTimeout);
221
226
 
222
227
  pointMenuHere();
223
- menu.showPopover();
228
+ // A hover open never runs the trigger's activation behaviour, so
229
+ // popovertarget alone doesn't register it as the invoker — say so
230
+ // here, or the menu light-dismisses whatever contains its trigger.
231
+ menu.showPopover({ source: el });
224
232
  }
225
233
  };
226
234
 
@@ -0,0 +1,335 @@
1
+ /* Manifest Edit */
2
+
3
+ @layer base, components, utilities;
4
+
5
+ /* Self-contained: every rule the plugin needs lives here, so this stylesheet works
6
+ on its own. Authors override without specificity battles — rules are :where()
7
+ wrapped and keyed off data-edit-* attributes, including the injected affordances
8
+ (handles, overlay, live region, toolbar, menu). No class names to collide with. */
9
+
10
+ @layer utilities {
11
+
12
+ /* ---- Editable area (armed) ----
13
+ Chrome is opt-in. Most uses of this plugin are a resize handle or a sortable
14
+ list, which should look like themselves; only .authoring gets the outline and
15
+ the region label. */
16
+ :where([data-edit-armed]) {
17
+ position: relative;
18
+ }
19
+
20
+ /* Quiet until touched. A page covered in dashed boxes reads as a diagram of
21
+ itself; the affordance only has to be there when you go looking for it. */
22
+ :where([data-edit-armed][data-edit-authoring]) {
23
+ outline: 1px dashed transparent;
24
+ outline-offset: 6px;
25
+ border-radius: var(--radius);
26
+ transition: outline-color 0.12s ease;
27
+ }
28
+
29
+ :where([data-edit-armed][data-edit-authoring]):hover,
30
+ :where([data-edit-armed][data-edit-authoring]):focus-within {
31
+ outline-color: var(--color-line);
32
+ }
33
+
34
+ :where([data-edit-armed][data-edit-authoring])::before {
35
+ content: attr(data-edit-label);
36
+ position: absolute;
37
+ inset-block-start: -1.5rem;
38
+ inset-inline-start: 0;
39
+ font-size: 0.7rem;
40
+ letter-spacing: 0.04em;
41
+ text-transform: uppercase;
42
+ color: var(--color-content-subtle);
43
+ pointer-events: none;
44
+ opacity: 0;
45
+ transition: opacity 0.12s ease;
46
+ }
47
+
48
+ :where([data-edit-armed][data-edit-authoring]):hover::before,
49
+ :where([data-edit-armed][data-edit-authoring]):focus-within::before {
50
+ opacity: 1;
51
+ }
52
+
53
+ /* ---- Reorder (pointer + keyboard; touch-action:none lets touch-drag beat scroll) ---- */
54
+ :where([data-edit-sortable]) {
55
+ cursor: grab;
56
+ touch-action: none;
57
+ transition: opacity 0.12s ease, outline-color 0.12s ease;
58
+ }
59
+
60
+ :where([data-edit-sortable]):hover,
61
+ :where([data-edit-sortable]):focus-within {
62
+ outline: 1px solid var(--edit-accent, var(--color-brand-content));
63
+ outline-offset: 3px;
64
+ }
65
+
66
+ :where([data-edit-sortable], [data-edit-movable]):focus-visible {
67
+ outline: 2px solid var(--edit-accent, var(--color-brand-content));
68
+ outline-offset: 3px;
69
+ }
70
+
71
+ /* Lifted out of the flow and following the pointer. */
72
+ :where([data-edit-sortable][data-edit-dragging]) {
73
+ z-index: 1000;
74
+ cursor: grabbing;
75
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
76
+ }
77
+
78
+ /* The stand-in holding the slot the drop would land in — a translucent copy of
79
+ the element by default, because the clearest preview of where it lands is the
80
+ thing itself. Restyle it freely: hide its contents for an empty outlined slot,
81
+ give it a background, animate it. */
82
+ :where([data-edit-ghost]) {
83
+ opacity: var(--edit-ghost-opacity, 0.4);
84
+ pointer-events: none;
85
+ }
86
+
87
+ /* Nothing else should react while a drag is in progress */
88
+ :where([data-edit-dragging-in]) {
89
+ cursor: grabbing;
90
+ user-select: none;
91
+
92
+ & :where([data-edit-sortable]:not([data-edit-dragging])) {
93
+ cursor: grabbing;
94
+ }
95
+ }
96
+
97
+ :where([data-edit-grabbed]) {
98
+ outline: 2px dashed var(--edit-accent, var(--color-brand-content));
99
+ outline-offset: 3px;
100
+ }
101
+
102
+ /* ---- Move (positioned children) ---- */
103
+ :where([data-edit-movable]) {
104
+ cursor: move;
105
+ touch-action: none;
106
+ }
107
+
108
+ :where([data-edit-movable]):hover {
109
+ outline: 1px solid var(--edit-accent, var(--color-brand-content));
110
+ outline-offset: 2px;
111
+ }
112
+
113
+ /* ---- Inline text ---- */
114
+ :where([data-edit-armed]) [contenteditable="true"] {
115
+ outline: 1px solid transparent;
116
+ outline-offset: 2px;
117
+ border-radius: 3px;
118
+ transition: outline-color 0.12s ease;
119
+ }
120
+
121
+ :where([data-edit-armed]) [contenteditable="true"]:hover {
122
+ outline-color: color-mix(in oklch, var(--edit-accent, var(--color-brand-content)) 45%, transparent);
123
+ }
124
+
125
+ :where([data-edit-armed]) [contenteditable="true"]:focus {
126
+ outline: 2px solid var(--edit-accent, var(--color-brand-content));
127
+ }
128
+
129
+ /* ---- Size handles ----
130
+ Injected as siblings of the element's own markup, so its native :hover and
131
+ :focus still fire and its body stays interactive. */
132
+ :where([data-edit-sizable]) {
133
+ position: relative;
134
+ }
135
+
136
+ /* Handles reveal on approach rather than sitting on the element permanently */
137
+ :where([data-edit-sizable]) :where([data-edit-handle])::before {
138
+ opacity: 0;
139
+ transition: opacity 0.12s ease, background-color 0.15s ease;
140
+ }
141
+
142
+ :where([data-edit-sizable]):hover :where([data-edit-handle])::before,
143
+ :where([data-edit-sizable]):focus-within :where([data-edit-handle])::before {
144
+ opacity: 1;
145
+ }
146
+
147
+ :where([data-edit-handle]) {
148
+ position: absolute;
149
+ z-index: 100;
150
+ background: transparent;
151
+ --h: var(--edit-size-handle, 1rem);
152
+
153
+ /* Hairline, revealed on hover */
154
+ &::before {
155
+ content: '';
156
+ position: absolute;
157
+ top: 50%;
158
+ left: 50%;
159
+ transform: translate(-50%, -50%);
160
+ width: 1px;
161
+ height: 1px;
162
+ background: transparent;
163
+ transition: background-color 0.15s ease;
164
+ pointer-events: none;
165
+ }
166
+
167
+ &:hover::before,
168
+ &:active::before {
169
+ background-color: var(--edit-accent, var(--color-brand-content));
170
+ }
171
+ }
172
+
173
+ :where([data-edit-handle]):focus-visible {
174
+ outline: 2px solid var(--edit-accent, var(--color-brand-content));
175
+ }
176
+
177
+ /* Edges span their axis; corners are square. A corner value contains a hyphen. */
178
+ :where([data-edit-handle="top"], [data-edit-handle="bottom"]) {
179
+ left: 0;
180
+ width: 100%;
181
+ height: var(--h);
182
+ cursor: ns-resize;
183
+
184
+ &::before {
185
+ width: 100%;
186
+ }
187
+ }
188
+
189
+ :where([data-edit-handle="left"], [data-edit-handle="right"], [data-edit-handle="start"], [data-edit-handle="end"]) {
190
+ top: 0;
191
+ width: var(--h);
192
+ height: 100%;
193
+ cursor: ew-resize;
194
+
195
+ &::before {
196
+ height: 100%;
197
+ }
198
+ }
199
+
200
+ :where([data-edit-handle*="-"]) {
201
+ width: var(--h);
202
+ height: var(--h);
203
+ }
204
+
205
+ /* Inset — each handle straddles its edge; a corner matches one rule per axis.
206
+ inset-inline-* carries the logical start/end values, so they flip for RTL. */
207
+ :where([data-edit-handle^="top"]) {
208
+ top: calc(var(--h) * -0.5);
209
+ }
210
+
211
+ :where([data-edit-handle^="bottom"]) {
212
+ bottom: calc(var(--h) * -0.5);
213
+ }
214
+
215
+ :where([data-edit-handle$="left"]) {
216
+ left: calc(var(--h) * -0.5);
217
+ }
218
+
219
+ :where([data-edit-handle$="right"]) {
220
+ right: calc(var(--h) * -0.5);
221
+ }
222
+
223
+ :where([data-edit-handle$="start"]) {
224
+ inset-inline-start: calc(var(--h) * -0.5);
225
+ }
226
+
227
+ :where([data-edit-handle$="end"]) {
228
+ inset-inline-end: calc(var(--h) * -0.5);
229
+ }
230
+
231
+ /* Corner cursors; the logical pair flips with direction. */
232
+ :where([data-edit-handle="top-left"], [data-edit-handle="bottom-right"], [data-edit-handle="top-start"], [data-edit-handle="bottom-end"]) {
233
+ cursor: nwse-resize;
234
+ }
235
+
236
+ :where([data-edit-handle="top-right"], [data-edit-handle="bottom-left"], [data-edit-handle="top-end"], [data-edit-handle="bottom-start"]) {
237
+ cursor: nesw-resize;
238
+ }
239
+
240
+ [dir=rtl] :where([data-edit-handle="top-start"], [data-edit-handle="bottom-end"]) {
241
+ cursor: nesw-resize;
242
+ }
243
+
244
+ [dir=rtl] :where([data-edit-handle="top-end"], [data-edit-handle="bottom-start"]) {
245
+ cursor: nwse-resize;
246
+ }
247
+
248
+ /* Collapsed past --edit-size-collapse-* — authors restyle freely */
249
+ :where([data-edit-collapsed]) {
250
+ opacity: 0.45;
251
+ }
252
+
253
+ /* Full-viewport capture layer during a size or move drag */
254
+ :where([data-edit-overlay]) {
255
+ position: fixed;
256
+ inset: 0;
257
+ z-index: 9999;
258
+ background: transparent;
259
+ }
260
+
261
+ /* Screen-reader announcements (keyboard grab / move / drop) */
262
+ :where([data-edit-live]) {
263
+ position: absolute;
264
+ width: 1px;
265
+ height: 1px;
266
+ margin: -1px;
267
+ padding: 0;
268
+ border: 0;
269
+ overflow: hidden;
270
+ clip-path: inset(50%);
271
+ white-space: nowrap;
272
+ }
273
+
274
+ @media (prefers-reduced-motion: reduce) {
275
+
276
+ :where([data-edit-armed]) > [draggable="true"],
277
+ :where([data-edit-handle])::before {
278
+ transition: none;
279
+ }
280
+ }
281
+ }
282
+
283
+ /* ---- Authoring chrome (toolbar + class/scope menu) ---- */
284
+ @layer components {
285
+
286
+ /* Presence is script's business — it knows which route is on screen. Whether it
287
+ is seen is not, so the toolbar is styled and revealed here. */
288
+ :where([data-edit-toolbar]) {
289
+ display: none;
290
+ position: fixed;
291
+ inset-block-end: 1rem;
292
+ inset-inline-end: 1rem;
293
+ z-index: 9999;
294
+ gap: 0.5rem;
295
+ align-items: center;
296
+
297
+ & button[disabled] {
298
+ opacity: 0.35;
299
+ pointer-events: none;
300
+ }
301
+ }
302
+
303
+ :root[data-edit-active] :where([data-edit-toolbar]) {
304
+ display: flex;
305
+ }
306
+
307
+ /* A popover: the top layer sits above every stacking context, so no handle,
308
+ overlay or z-index anywhere on the page can paint over it. */
309
+ :where([data-edit-menu]) {
310
+ position: fixed;
311
+ margin: 0;
312
+ width: 16rem;
313
+ padding: 0.6rem;
314
+ display: flex;
315
+ flex-direction: column;
316
+ gap: 0.4rem;
317
+ background: var(--color-surface-1);
318
+ border: 1px solid var(--color-line);
319
+ border-radius: var(--radius);
320
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.2);
321
+
322
+ & input {
323
+ width: 100%;
324
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
325
+ font-size: 0.75rem;
326
+ }
327
+
328
+ & small {
329
+ font-size: 0.7rem;
330
+ text-transform: uppercase;
331
+ letter-spacing: 0.05em;
332
+ color: var(--color-content-subtle);
333
+ }
334
+ }
335
+ }