surf-core 0.1.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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Drag & Drop Plugin for Surf
3
+ *
4
+ * Enables declarative drag and drop functionality.
5
+ * Usage:
6
+ * <div d-drag-zone>
7
+ * <div d-draggable="true" d-drag-data='{"id": 1}'>Item</div>
8
+ * </div>
9
+ */
10
+ const DragAndDrop = {
11
+ name: 'drag-and-drop',
12
+
13
+ install(Surf, options = {}) {
14
+ let draggedItem = null;
15
+
16
+ // Helper to initialize draggable elements
17
+ const initDraggables = (root = document) => {
18
+ root.querySelectorAll('[d-draggable="true"]').forEach(el => {
19
+ el.setAttribute('draggable', 'true');
20
+ });
21
+ };
22
+
23
+ // Initialize existing elements
24
+ initDraggables();
25
+
26
+ // Observe for new elements
27
+ const observer = new MutationObserver((mutations) => {
28
+ mutations.forEach((mutation) => {
29
+ mutation.addedNodes.forEach((node) => {
30
+ if (node.nodeType === 1) { // ELEMENT_NODE
31
+ if (node.hasAttribute('d-draggable')) {
32
+ node.setAttribute('draggable', 'true');
33
+ }
34
+ // Check children
35
+ initDraggables(node);
36
+ }
37
+ });
38
+ });
39
+ });
40
+
41
+ observer.observe(document.body, { childList: true, subtree: true });
42
+
43
+ // Global drag start handler
44
+ document.addEventListener('dragstart', (e) => {
45
+ // Allow preventing drag on specific children (like buttons, inputs, etc)
46
+ if (e.target.closest('[d-no-drag]')) {
47
+ e.preventDefault();
48
+ return;
49
+ }
50
+
51
+ if (!e.target.closest('[d-draggable="true"]')) return;
52
+
53
+ const target = e.target.closest('[d-draggable="true"]');
54
+ draggedItem = target;
55
+
56
+ // Get data
57
+ const data = target.getAttribute('d-drag-data');
58
+ if (data) {
59
+ e.dataTransfer.setData('text/plain', data);
60
+ }
61
+
62
+ e.dataTransfer.effectAllowed = 'move';
63
+ target.classList.add('dragging');
64
+ });
65
+
66
+ // Global drag end handler
67
+ document.addEventListener('dragend', (e) => {
68
+ if (draggedItem) {
69
+ draggedItem.classList.remove('dragging');
70
+ draggedItem = null;
71
+ }
72
+
73
+ // Cleanup drag-over classes
74
+ document.querySelectorAll('.drag-over').forEach(el => {
75
+ el.classList.remove('drag-over');
76
+ });
77
+ });
78
+
79
+ // Global drag over handler
80
+ document.addEventListener('dragover', (e) => {
81
+ const dropZone = e.target.closest('[d-drop-zone]');
82
+ if (!dropZone) return;
83
+
84
+ e.preventDefault(); // Allow drop
85
+ e.dataTransfer.dropEffect = 'move';
86
+
87
+ dropZone.classList.add('drag-over');
88
+ });
89
+
90
+ // Global drag leave handler
91
+ document.addEventListener('dragleave', (e) => {
92
+ const dropZone = e.target.closest('[d-drop-zone]');
93
+ if (!dropZone) return;
94
+
95
+ // Only remove if we really left the element (not just entered a child)
96
+ const rect = dropZone.getBoundingClientRect();
97
+ const x = e.clientX;
98
+ const y = e.clientY;
99
+
100
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
101
+ dropZone.classList.remove('drag-over');
102
+ }
103
+ });
104
+
105
+ // Global drop handler
106
+ document.addEventListener('drop', async (e) => {
107
+ const dropZone = e.target.closest('[d-drop-zone]');
108
+ if (!dropZone) return;
109
+
110
+ e.preventDefault();
111
+ dropZone.classList.remove('drag-over');
112
+
113
+ const url = dropZone.getAttribute('d-drop-url');
114
+ if (!url) return;
115
+
116
+ try {
117
+ const rawData = e.dataTransfer.getData('text/plain');
118
+ if (!rawData) return;
119
+
120
+ const data = JSON.parse(rawData);
121
+
122
+ // Add drop zone data if exists
123
+ const zoneDataRaw = dropZone.getAttribute('d-drop-data');
124
+ if (zoneDataRaw) {
125
+ const zoneData = JSON.parse(zoneDataRaw);
126
+ Object.assign(data, zoneData);
127
+ }
128
+
129
+ const formData = new URLSearchParams();
130
+ for (const [key, value] of Object.entries(data)) {
131
+ formData.append(key, value);
132
+ }
133
+
134
+ const response = await fetch(url, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/x-www-form-urlencoded',
138
+ },
139
+ body: formData
140
+ });
141
+
142
+ if (response.ok) {
143
+ const patchHtml = await response.text();
144
+ if (Surf && Surf.applyPatch) {
145
+ Surf.applyPatch(patchHtml);
146
+ }
147
+ } else {
148
+ console.error('[Surf DnD] Drop request failed', response.status);
149
+ }
150
+ } catch (err) {
151
+ console.error('[Surf DnD] Drop error', err);
152
+ }
153
+ });
154
+
155
+ console.log('[Surf] Drag & Drop plugin installed');
156
+ }
157
+ };
158
+
159
+ export default DragAndDrop;
package/src/pulse.js ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Pulse Module
3
+ *
4
+ * A Pulse represents user intent that triggers a server interaction.
5
+ * Examples: navigation, form submission, refresh.
6
+ *
7
+ * Defined with: d-pulse, d-target
8
+ *
9
+ * Pulse types:
10
+ * - navigate: GET request, replace surface
11
+ * - commit: POST form data, apply patch
12
+ * - refresh: GET current URL, refresh surface
13
+ */
14
+
15
+ import * as Surface from './surface.js';
16
+ import * as Patch from './patch.js';
17
+ import * as Echo from './echo.js';
18
+
19
+ const PULSE_ATTR = 'd-pulse';
20
+ const TARGET_ATTR = 'd-target';
21
+ const ACTION_ATTR = 'd-action';
22
+
23
+ // Event emitter for pulse lifecycle
24
+ const listeners = {
25
+ 'before:pulse': [],
26
+ 'after:patch': [],
27
+ 'error:network': []
28
+ };
29
+
30
+ /**
31
+ * Emit an event to listeners
32
+ * @param {string} event
33
+ * @param {Object} detail
34
+ */
35
+ function emit(event, detail) {
36
+ if (listeners[event]) {
37
+ listeners[event].forEach(cb => {
38
+ try {
39
+ cb(detail);
40
+ } catch (e) {
41
+ console.error(`[Surf] Error in ${event} listener:`, e);
42
+ }
43
+ });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Register an event listener
49
+ * @param {string} event
50
+ * @param {function} callback
51
+ */
52
+ export function on(event, callback) {
53
+ if (listeners[event]) {
54
+ listeners[event].push(callback);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Remove an event listener
60
+ * @param {string} event
61
+ * @param {function} callback
62
+ */
63
+ export function off(event, callback) {
64
+ if (listeners[event]) {
65
+ const index = listeners[event].indexOf(callback);
66
+ if (index > -1) {
67
+ listeners[event].splice(index, 1);
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Apply patches to surfaces with Echo preservation
74
+ * @param {Array<{target: string, content: string}>} patches
75
+ */
76
+ function applyPatches(patches) {
77
+ patches.forEach(({ target, content }) => {
78
+ const surface = Surface.getBySelector(target) || document.querySelector(target);
79
+
80
+ if (!surface) {
81
+ console.warn(`[Surf] Target not found for patch: ${target}`);
82
+ return;
83
+ }
84
+
85
+ Echo.withPreservation(surface, content, () => {
86
+ Surface.replace(target, content);
87
+ });
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Send a pulse request to the server
93
+ * @param {string} url
94
+ * @param {Object} options
95
+ * @param {string} targetSelector
96
+ */
97
+ async function sendPulse(url, options, targetSelector) {
98
+ emit('before:pulse', { url, options, target: targetSelector });
99
+
100
+ try {
101
+ const response = await fetch(url, {
102
+ ...options,
103
+ headers: {
104
+ 'Accept': 'text/html',
105
+ 'X-Surf-Request': 'true',
106
+ ...options.headers
107
+ }
108
+ });
109
+
110
+ if (!response.ok) {
111
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
112
+ }
113
+
114
+ const html = await response.text();
115
+
116
+ // Check if response is a patch
117
+ if (Patch.isPatch(html)) {
118
+ const patches = Patch.parse(html);
119
+ applyPatches(patches);
120
+ } else {
121
+ // Treat as single surface replacement
122
+ if (targetSelector) {
123
+ const surface = Surface.getBySelector(targetSelector) || document.querySelector(targetSelector);
124
+ if (surface) {
125
+ // Check for swap mode (inner, append, prepend) from options or element
126
+ const swapMode = options.swap || surface.getAttribute('d-swap') || 'inner';
127
+
128
+ Echo.withPreservation(surface, html, () => {
129
+ if (swapMode === 'append') {
130
+ Surface.append(surface, html);
131
+ } else if (swapMode === 'prepend') {
132
+ Surface.prepend(surface, html);
133
+ } else {
134
+ Surface.replace(surface, html);
135
+ }
136
+ });
137
+ }
138
+ }
139
+ }
140
+
141
+ emit('after:patch', { url, target: targetSelector });
142
+
143
+ } catch (error) {
144
+ console.error('[Surf] Pulse error:', error);
145
+ emit('error:network', { url, error });
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Handle navigate pulse (GET request)
151
+ * @param {string} url
152
+ * @param {string} targetSelector
153
+ * @param {Object} options
154
+ */
155
+ export async function navigate(url, targetSelector, options = {}) {
156
+ const target = targetSelector || 'body';
157
+ await sendPulse(url, { method: 'GET', ...options }, target);
158
+
159
+ // Update browser history
160
+ if (target) {
161
+ history.pushState({ surf: true, url, target }, '', url);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Handle commit pulse (POST form data)
167
+ * @param {HTMLFormElement} form
168
+ * @param {string} targetSelector
169
+ */
170
+ export async function commit(form, targetSelector) {
171
+ const method = form.method?.toUpperCase() || 'POST';
172
+ let url = form.action || window.location.href;
173
+ const formData = new FormData(form);
174
+ const swap = form.getAttribute('d-swap');
175
+ const options = swap ? { swap } : {};
176
+ const target = targetSelector || 'body';
177
+
178
+ // Convert FormData to URLSearchParams for standard form encoding
179
+ const params = new URLSearchParams();
180
+ formData.forEach((value, key) => params.append(key, value));
181
+
182
+ // GET requests cannot have a body - append as URL params instead
183
+ if (method === 'GET') {
184
+ const separator = url.includes('?') ? '&' : '?';
185
+ url = url + separator + params.toString();
186
+
187
+ await sendPulse(url, { method: 'GET', ...options }, target);
188
+ } else {
189
+ await sendPulse(url, {
190
+ method,
191
+ headers: {
192
+ 'Content-Type': 'application/x-www-form-urlencoded'
193
+ },
194
+ body: params.toString(),
195
+ ...options
196
+ }, target);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Handle refresh pulse (GET current content)
202
+ * @param {string} targetSelector
203
+ */
204
+ export async function refresh(targetSelector) {
205
+ const url = window.location.href;
206
+ const target = targetSelector || 'body';
207
+ const surface = Surface.getBySelector(target) || document.querySelector(target);
208
+ const swap = surface?.getAttribute('d-swap'); // Refresh usually respects surface preference
209
+ await sendPulse(url, { method: 'GET', swap }, target);
210
+ }
211
+
212
+ /**
213
+ * Handle action pulse (POST data to server)
214
+ * @param {string} url - Action endpoint URL
215
+ * @param {Object} data - Data to send
216
+ * @param {string} targetSelector - Surface to update with response
217
+ * @param {Object} options
218
+ */
219
+ export async function action(url, data = {}, targetSelector, options = {}) {
220
+ const target = targetSelector || 'body';
221
+ await sendPulse(url, {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Content-Type': 'application/json'
225
+ },
226
+ body: JSON.stringify(data),
227
+ ...options
228
+ }, target);
229
+ }
230
+
231
+ /**
232
+ * Handle click events on pulse elements
233
+ * @param {Event} event
234
+ */
235
+ async function handleClick(event) {
236
+ const element = event.target.closest(`[${PULSE_ATTR}]`);
237
+ if (!element) return;
238
+
239
+ const pulseType = element.getAttribute(PULSE_ATTR);
240
+ const targetSelector = element.getAttribute(TARGET_ATTR);
241
+ const actionUrl = element.getAttribute(ACTION_ATTR);
242
+ const swap = element.getAttribute('d-swap');
243
+ const options = swap ? { swap } : {};
244
+
245
+ // Handle anchor navigation
246
+ if (element.tagName === 'A' && pulseType === 'navigate') {
247
+ event.preventDefault();
248
+ const url = element.href;
249
+ navigate(url, targetSelector, options);
250
+ return;
251
+ }
252
+
253
+ // Handle refresh
254
+ if (pulseType === 'refresh') {
255
+ event.preventDefault();
256
+ refresh(targetSelector);
257
+ return;
258
+ }
259
+
260
+ // Handle action - send POST to d-action URL
261
+ if (pulseType === 'action' && actionUrl) {
262
+ event.preventDefault();
263
+
264
+ // Collect data from data-* attributes on the element
265
+ const data = {};
266
+ for (const attr of element.attributes) {
267
+ if (attr.name.startsWith('data-') && attr.name !== 'data-surf-ready') {
268
+ const key = attr.name.slice(5); // Remove 'data-' prefix
269
+ data[key] = attr.value;
270
+ }
271
+ }
272
+
273
+ // Also include Cell state if element is inside a d-cell
274
+ const parentCell = element.closest('[d-cell]');
275
+ if (parentCell) {
276
+ const Cell = await import('./cell.js');
277
+ const cellState = Cell.default ? Cell.default.getState(parentCell) : Cell.getState(parentCell);
278
+ if (cellState) {
279
+ Object.assign(data, cellState);
280
+ }
281
+ }
282
+
283
+ action(actionUrl, data, targetSelector, options);
284
+ return;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Handle form submission on pulse elements
290
+ * @param {Event} event
291
+ */
292
+ function handleSubmit(event) {
293
+ const form = event.target;
294
+ if (!form.hasAttribute(PULSE_ATTR)) return;
295
+
296
+ const pulseType = form.getAttribute(PULSE_ATTR);
297
+ const targetSelector = form.getAttribute(TARGET_ATTR);
298
+
299
+ if (pulseType === 'commit') {
300
+ event.preventDefault();
301
+ commit(form, targetSelector);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Handle browser back/forward navigation
307
+ * @param {PopStateEvent} event
308
+ */
309
+ function handlePopState(event) {
310
+ if (event.state?.surf) {
311
+ sendPulse(event.state.url, { method: 'GET' }, event.state.target);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Initialize pulse handling
317
+ */
318
+ export function init() {
319
+ // Delegate click events
320
+ document.addEventListener('click', handleClick);
321
+
322
+ // Delegate form submissions
323
+ document.addEventListener('submit', handleSubmit);
324
+
325
+ // Handle browser navigation
326
+ window.addEventListener('popstate', handlePopState);
327
+
328
+ // Handle browser navigation
329
+ window.addEventListener('popstate', handlePopState);
330
+ }
331
+
332
+ /**
333
+ * Programmatic navigation (Surf.go)
334
+ * @param {string} url
335
+ * @param {Object} options
336
+ */
337
+ export async function go(url, options = {}) {
338
+ const target = options.target || 'body';
339
+ await navigate(url, target, options);
340
+ }
341
+
342
+ /**
343
+ * Programmatic form submission (Surf.submit)
344
+ * @param {Element} element - Element within a form
345
+ */
346
+ export function submit(element) {
347
+ const form = element.tagName === 'FORM' ? element : element.closest('form');
348
+ if (form) {
349
+ if (form.requestSubmit) {
350
+ form.requestSubmit();
351
+ } else {
352
+ form.submit();
353
+ }
354
+ } else {
355
+ console.warn('[Surf] No form found to submit for element:', element);
356
+ }
357
+ }
358
+
359
+ export default {
360
+ on,
361
+ off,
362
+ navigate,
363
+ commit,
364
+ refresh,
365
+ action,
366
+ go,
367
+ submit,
368
+ init
369
+ };