@0m0g1/griot 0.1.4 → 0.1.5

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/src/Griot.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // Keyboard attachKeyboardHandler, getCursorOffset, getSelectionOffsets,
17
17
  // setCursorOffset, focusAtEnd, focusAtStart
18
18
  // URL helpers resolveYouTube, resolveVimeo, resolveSpotify, resolveSoundCloud
19
+ // Gallery renderGallery, lightbox
19
20
  // ─────────────────────────────────────────────────────────────────────────────
20
21
 
21
22
  export {
@@ -38,6 +39,8 @@ export { renderInlineToDOM, renderInlineToHTML, escHtml, escAttr } from './inlin
38
39
  export { getBlockDef, getAllTypes, getTypesByCategory, defaultMeta } from './blocks/BlockSchema.js';
39
40
  export { default as BlockSchema } from './blocks/BlockSchema.js';
40
41
  export { renderBlock, resolveYouTube, resolveVimeo, resolveSpotify, resolveSoundCloud } from './blocks/BlockRenderer.js';
42
+ export { renderGallery } from './blocks/GalleryRenderer.js';
43
+ export { lightbox } from './blocks/Lightbox.js';
41
44
 
42
45
  export { Editor } from './editor/Editor.js';
43
46
  export { FormatToolbar } from './editor/FormatToolbar.js';
@@ -0,0 +1,417 @@
1
+ // ─── GalleryRenderer.js ───────────────────────────────────────────────────────
2
+ // Renders a gallery of image items in one of four layouts.
3
+ // All layouts open the shared lightbox singleton on click.
4
+ //
5
+ // Usage:
6
+ // import { renderGallery } from './GalleryRenderer.js';
7
+ // const el = renderGallery(items, 'carousel');
8
+ // container.appendChild(el);
9
+ //
10
+ // items shape: { src?, url?, alt?, alt_text?, caption? }[]
11
+ // layouts: 'grid' | 'masonry' | 'carousel' | 'strip'
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ import { lightbox } from './Lightbox.js';
15
+
16
+ const VALID_LAYOUTS = new Set(['grid', 'masonry', 'carousel', 'strip']);
17
+
18
+ // ── Public ────────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Render a gallery element.
22
+ * @param {object[]} items
23
+ * @param {'grid'|'masonry'|'carousel'|'strip'} layout
24
+ * @returns {HTMLElement}
25
+ */
26
+ export function renderGallery(items = [], layout = 'grid') {
27
+ _injectStyles();
28
+
29
+ const l = VALID_LAYOUTS.has(layout) ? layout : 'grid';
30
+ const wrap = document.createElement('div');
31
+ wrap.className = `griot-gallery griot-gallery--${l}`;
32
+ wrap.dataset.layout = l;
33
+
34
+ if (!items.length) {
35
+ const empty = document.createElement('div');
36
+ empty.className = 'griot-gallery__empty';
37
+ empty.textContent = 'No images yet';
38
+ wrap.appendChild(empty);
39
+ return wrap;
40
+ }
41
+
42
+ switch (l) {
43
+ case 'carousel': return _carousel(items, wrap);
44
+ case 'masonry': return _masonry(items, wrap);
45
+ case 'strip': return _strip(items, wrap);
46
+ default: return _grid(items, wrap);
47
+ }
48
+ }
49
+
50
+ // ── Grid ──────────────────────────────────────────────────────────────────────
51
+
52
+ function _grid(items, wrap) {
53
+ items.forEach((item, i) => {
54
+ const el = _itemEl(item, i, items);
55
+ el.className = 'griot-gallery__item griot-gallery__item--grid';
56
+ wrap.appendChild(el);
57
+ });
58
+ return wrap;
59
+ }
60
+
61
+ // ── Masonry ───────────────────────────────────────────────────────────────────
62
+
63
+ function _masonry(items, wrap) {
64
+ items.forEach((item, i) => {
65
+ const el = _itemEl(item, i, items);
66
+ el.className = 'griot-gallery__item griot-gallery__item--masonry';
67
+ wrap.appendChild(el);
68
+ });
69
+ return wrap;
70
+ }
71
+
72
+ // ── Strip ─────────────────────────────────────────────────────────────────────
73
+
74
+ function _strip(items, wrap) {
75
+ const inner = document.createElement('div');
76
+ inner.className = 'griot-gallery__strip-inner';
77
+
78
+ items.forEach((item, i) => {
79
+ const el = _itemEl(item, i, items);
80
+ el.className = 'griot-gallery__item griot-gallery__item--strip';
81
+ inner.appendChild(el);
82
+ });
83
+
84
+ wrap.appendChild(inner);
85
+ return wrap;
86
+ }
87
+
88
+ // ── Shared item element (grid / masonry / strip) ──────────────────────────────
89
+
90
+ function _itemEl(item, index, allItems) {
91
+ const el = document.createElement('div');
92
+
93
+ const img = document.createElement('img');
94
+ img.src = item.src ?? item.url ?? '';
95
+ img.alt = item.alt ?? item.alt_text ?? item.caption ?? '';
96
+ img.loading = index < 6 ? 'eager' : 'lazy';
97
+ img.decoding = 'async';
98
+ img.draggable = false;
99
+
100
+ img.addEventListener('click', () => lightbox.open(allItems, index));
101
+
102
+ el.appendChild(img);
103
+
104
+ if (item.caption) {
105
+ const cap = document.createElement('p');
106
+ cap.className = 'griot-gallery__caption';
107
+ cap.textContent = item.caption;
108
+ el.appendChild(cap);
109
+ }
110
+
111
+ return el;
112
+ }
113
+
114
+ // ── Carousel ──────────────────────────────────────────────────────────────────
115
+
116
+ function _carousel(items, wrap) {
117
+ let idx = 0;
118
+
119
+ // ── DOM structure ────────────────────────────────────────────────────────
120
+ // .griot-carousel__viewport (clips)
121
+ // .griot-carousel__track (slides)
122
+ // .griot-carousel__slide × N
123
+ // .griot-carousel__controls (prev · counter · next)
124
+ // .griot-carousel__dots (dot buttons, hidden if > 12 items)
125
+
126
+ const viewport = document.createElement('div');
127
+ viewport.className = 'griot-carousel__viewport';
128
+
129
+ const track = document.createElement('div');
130
+ track.className = 'griot-carousel__track';
131
+
132
+ items.forEach((item, i) => {
133
+ const slide = document.createElement('div');
134
+ slide.className = 'griot-carousel__slide';
135
+
136
+ const img = document.createElement('img');
137
+ img.src = item.src ?? item.url ?? '';
138
+ img.alt = item.alt ?? item.alt_text ?? item.caption ?? '';
139
+ img.loading = i === 0 ? 'eager' : 'lazy';
140
+ img.decoding = 'async';
141
+ img.draggable = false;
142
+
143
+ // Click on carousel image → open lightbox at CURRENT idx (not i, since user
144
+ // may have navigated away from the first image)
145
+ img.addEventListener('click', () => lightbox.open(items, idx));
146
+
147
+ slide.appendChild(img);
148
+
149
+ if (item.caption) {
150
+ const cap = document.createElement('p');
151
+ cap.className = 'griot-gallery__caption griot-carousel__caption';
152
+ cap.textContent = item.caption;
153
+ slide.appendChild(cap);
154
+ }
155
+
156
+ track.appendChild(slide);
157
+ });
158
+
159
+ viewport.appendChild(track);
160
+
161
+ // Controls bar
162
+ const controls = document.createElement('div');
163
+ controls.className = 'griot-carousel__controls';
164
+
165
+ const prevBtn = _carBtn('‹', 'griot-carousel__btn griot-carousel__btn--prev', 'Previous');
166
+ const nextBtn = _carBtn('›', 'griot-carousel__btn griot-carousel__btn--next', 'Next');
167
+ const counter = document.createElement('span');
168
+ counter.className = 'griot-carousel__counter';
169
+
170
+ controls.append(prevBtn, counter, nextBtn);
171
+
172
+ // Dot strip
173
+ const dots = document.createElement('div');
174
+ dots.className = 'griot-carousel__dots';
175
+
176
+ const dotEls = items.map((_, i) => {
177
+ const d = document.createElement('button');
178
+ d.type = 'button';
179
+ d.className = 'griot-carousel__dot';
180
+ d.setAttribute('aria-label', `Image ${i + 1}`);
181
+ d.addEventListener('click', () => goTo(i));
182
+ dots.appendChild(d);
183
+ return d;
184
+ });
185
+
186
+ // ── Navigation logic ──────────────────────────────────────────────────────
187
+
188
+ function goTo(n, animate = true) {
189
+ idx = Math.max(0, Math.min(n, items.length - 1));
190
+
191
+ if (!animate) {
192
+ track.style.transition = 'none';
193
+ requestAnimationFrame(() => { track.style.transition = ''; });
194
+ }
195
+
196
+ track.style.transform = `translateX(-${idx * 100}%)`;
197
+ counter.textContent = `${idx + 1} / ${items.length}`;
198
+
199
+ prevBtn.disabled = items.length <= 1;
200
+ nextBtn.disabled = items.length <= 1;
201
+ prevBtn.classList.toggle('is-edge', idx === 0);
202
+ nextBtn.classList.toggle('is-edge', idx === items.length - 1);
203
+
204
+ dotEls.forEach((d, i) => {
205
+ d.classList.toggle('is-active', i === idx);
206
+ d.setAttribute('aria-pressed', String(i === idx));
207
+ });
208
+ }
209
+
210
+ prevBtn.addEventListener('click', () => goTo(idx - 1));
211
+ nextBtn.addEventListener('click', () => goTo(idx + 1));
212
+
213
+ // Touch / swipe on the viewport
214
+ let touchX = 0, touchY = 0, isScrolling = null;
215
+
216
+ viewport.addEventListener('touchstart', e => {
217
+ touchX = e.touches[0].clientX;
218
+ touchY = e.touches[0].clientY;
219
+ isScrolling = null;
220
+ }, { passive: true });
221
+
222
+ viewport.addEventListener('touchmove', e => {
223
+ if (isScrolling === null) {
224
+ const dx = Math.abs(e.touches[0].clientX - touchX);
225
+ const dy = Math.abs(e.touches[0].clientY - touchY);
226
+ isScrolling = dy > dx;
227
+ }
228
+ }, { passive: true });
229
+
230
+ viewport.addEventListener('touchend', e => {
231
+ if (isScrolling) return;
232
+ const dx = e.changedTouches[0].clientX - touchX;
233
+ if (Math.abs(dx) > 40) goTo(dx < 0 ? idx + 1 : idx - 1);
234
+ }, { passive: true });
235
+
236
+ // Keyboard when carousel has focus
237
+ wrap.tabIndex = 0;
238
+ wrap.addEventListener('keydown', e => {
239
+ if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(idx - 1); }
240
+ if (e.key === 'ArrowRight') { e.preventDefault(); goTo(idx + 1); }
241
+ });
242
+
243
+ // Accessibility
244
+ wrap.setAttribute('role', 'region');
245
+ wrap.setAttribute('aria-roledescription', 'carousel');
246
+ wrap.setAttribute('aria-label', `Gallery, ${items.length} images`);
247
+ viewport.setAttribute('aria-live', 'polite');
248
+
249
+ dots.hidden = items.length < 2 || items.length > 14;
250
+
251
+ wrap.append(viewport, controls, dots);
252
+
253
+ goTo(0, false); // initial position, no animation
254
+ return wrap;
255
+ }
256
+
257
+ function _carBtn(label, className, ariaLabel) {
258
+ const b = document.createElement('button');
259
+ b.type = 'button';
260
+ b.className = className;
261
+ b.setAttribute('aria-label', ariaLabel);
262
+ b.textContent = label;
263
+ return b;
264
+ }
265
+
266
+ // ── Style injection ───────────────────────────────────────────────────────────
267
+
268
+ let _stylesInjected = false;
269
+ function _injectStyles() {
270
+ if (_stylesInjected || typeof document === 'undefined') return;
271
+ _stylesInjected = true;
272
+ const s = document.createElement('style');
273
+ s.id = 'griot-gallery-styles';
274
+ s.textContent = `
275
+ /* ── Shared gallery wrapper ─────────────────────────────────────────────── */
276
+ .griot-gallery {
277
+ width: 100%;
278
+ box-sizing: border-box;
279
+ }
280
+ .griot-gallery__empty {
281
+ font-size: 13px; color: #64748b;
282
+ padding: 24px; text-align: center;
283
+ border: 2px dashed rgba(255,255,255,0.10); border-radius: 10px;
284
+ }
285
+ .griot-gallery__caption {
286
+ font-size: 12px; color: #64748b;
287
+ margin: 5px 0 0; text-align: center; line-height: 1.4;
288
+ }
289
+
290
+ /* ── Grid ───────────────────────────────────────────────────────────────── */
291
+ .griot-gallery--grid {
292
+ display: grid;
293
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
294
+ gap: 8px;
295
+ }
296
+ .griot-gallery__item--grid {
297
+ cursor: zoom-in;
298
+ border-radius: 8px; overflow: hidden;
299
+ background: rgba(255,255,255,0.04);
300
+ }
301
+ .griot-gallery__item--grid img {
302
+ width: 100%; display: block;
303
+ aspect-ratio: 4/3; object-fit: cover;
304
+ transition: transform 0.22s;
305
+ }
306
+ .griot-gallery__item--grid:hover img { transform: scale(1.04); }
307
+
308
+ /* ── Masonry ────────────────────────────────────────────────────────────── */
309
+ .griot-gallery--masonry {
310
+ columns: 2 200px; gap: 8px;
311
+ }
312
+ .griot-gallery__item--masonry {
313
+ break-inside: avoid; margin-bottom: 8px;
314
+ cursor: zoom-in; border-radius: 8px; overflow: hidden;
315
+ background: rgba(255,255,255,0.04);
316
+ }
317
+ .griot-gallery__item--masonry img {
318
+ width: 100%; display: block;
319
+ transition: transform 0.22s;
320
+ }
321
+ .griot-gallery__item--masonry:hover img { transform: scale(1.02); }
322
+
323
+ /* ── Strip ──────────────────────────────────────────────────────────────── */
324
+ .griot-gallery--strip { overflow: hidden; }
325
+
326
+ .griot-gallery__strip-inner {
327
+ display: flex; gap: 8px;
328
+ overflow-x: auto; padding-bottom: 6px;
329
+ scroll-snap-type: x mandatory;
330
+ -webkit-overflow-scrolling: touch;
331
+ }
332
+ .griot-gallery__strip-inner::-webkit-scrollbar { height: 4px; }
333
+ .griot-gallery__strip-inner::-webkit-scrollbar-track { background: transparent; }
334
+ .griot-gallery__strip-inner::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
335
+
336
+ .griot-gallery__item--strip {
337
+ flex-shrink: 0; scroll-snap-align: start;
338
+ width: 180px; height: 120px;
339
+ border-radius: 8px; overflow: hidden; cursor: zoom-in;
340
+ background: rgba(255,255,255,0.04);
341
+ }
342
+ .griot-gallery__item--strip img {
343
+ width: 100%; height: 100%; object-fit: cover;
344
+ transition: transform 0.22s;
345
+ }
346
+ .griot-gallery__item--strip:hover img { transform: scale(1.04); }
347
+
348
+ /* ── Carousel ───────────────────────────────────────────────────────────── */
349
+ .griot-gallery--carousel { outline: none; }
350
+
351
+ .griot-carousel__viewport {
352
+ overflow: hidden; border-radius: 10px;
353
+ background: rgba(0,0,0,0.15);
354
+ }
355
+ .griot-carousel__track {
356
+ display: flex;
357
+ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
358
+ will-change: transform;
359
+ }
360
+ .griot-carousel__slide {
361
+ flex: 0 0 100%; min-width: 0;
362
+ display: flex; flex-direction: column; align-items: center;
363
+ }
364
+ .griot-carousel__slide img {
365
+ width: 100%; max-height: 420px;
366
+ object-fit: contain; display: block;
367
+ cursor: zoom-in; user-select: none;
368
+ }
369
+ .griot-carousel__caption {
370
+ padding: 8px 16px 10px;
371
+ }
372
+
373
+ /* Controls bar */
374
+ .griot-carousel__controls {
375
+ display: flex; align-items: center; justify-content: center;
376
+ gap: 16px; margin-top: 10px;
377
+ }
378
+ .griot-carousel__btn {
379
+ background: rgba(255,255,255,0.06);
380
+ border: 1px solid rgba(255,255,255,0.12);
381
+ border-radius: 6px; color: #94a3b8;
382
+ width: 34px; height: 34px; font-size: 18px; line-height: 1;
383
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
384
+ transition: background 0.15s, color 0.15s, opacity 0.15s;
385
+ }
386
+ .griot-carousel__btn:disabled { opacity: 0.3; cursor: not-allowed; }
387
+ .griot-carousel__btn:not(:disabled):hover {
388
+ background: rgba(99,102,241,0.20); color: #a5b4fc;
389
+ }
390
+ .griot-carousel__btn.is-edge { opacity: 0.45; }
391
+ .griot-carousel__btn.is-edge:hover { opacity: 1; }
392
+ .griot-carousel__counter { font-size: 13px; color: #64748b; min-width: 48px; text-align: center; }
393
+
394
+ /* Dots */
395
+ .griot-carousel__dots {
396
+ display: flex; justify-content: center; gap: 6px; margin-top: 10px;
397
+ }
398
+ .griot-carousel__dot {
399
+ width: 8px; height: 8px; border-radius: 50%;
400
+ background: rgba(255,255,255,0.18); border: none; padding: 0;
401
+ cursor: pointer; transition: background 0.2s, transform 0.2s;
402
+ }
403
+ .griot-carousel__dot:hover { background: rgba(255,255,255,0.40); }
404
+ .griot-carousel__dot.is-active {
405
+ background: #6366f1; transform: scale(1.3);
406
+ }
407
+
408
+ @media (max-width: 480px) {
409
+ .griot-gallery--grid {
410
+ grid-template-columns: repeat(2, 1fr);
411
+ }
412
+ .griot-gallery--masonry { columns: 2; }
413
+ .griot-gallery__item--strip { width: 140px; height: 96px; }
414
+ }
415
+ `;
416
+ document.head.appendChild(s);
417
+ }