@asd20/ui-next 2.17.0 → 2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ # [2.18.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.17.0...ui-next-v2.18.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * add tiptap gallery support ([7371f56](https://github.com/academydistrict20/asd20-ui-next/commit/7371f561c6c0a3e6a2275a9537beba72c86e6837))
9
+
3
10
  # [2.17.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.16.0...ui-next-v2.17.0) (2026-08-05)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -158,6 +158,37 @@ export default {
158
158
  margin-bottom: 0;
159
159
  }
160
160
 
161
+ :deep(.asd20-message-image-gallery) {
162
+ box-sizing: border-box;
163
+ max-width: 100%;
164
+ margin: 1rem 0;
165
+ overflow: hidden;
166
+ }
167
+
168
+ :deep(.asd20-message-image-gallery__images) {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ gap: 0.75rem;
173
+ width: 100%;
174
+ height: auto !important;
175
+ max-height: min(32rem, 70vh) !important;
176
+ overflow: hidden;
177
+ }
178
+
179
+ :deep(.asd20-message-image-gallery__images > img) {
180
+ box-sizing: border-box;
181
+ display: block;
182
+ flex: 0 1 auto;
183
+ width: auto !important;
184
+ min-width: 0;
185
+ max-width: none !important;
186
+ height: auto !important;
187
+ max-height: min(32rem, 70vh) !important;
188
+ margin: 0 !important;
189
+ object-fit: contain;
190
+ }
191
+
161
192
  :deep(.asd20-message-video) {
162
193
  max-width: 100%;
163
194
  margin: 0.75rem 0;
@@ -27,6 +27,7 @@ const STANDARD_BLOCK_NODE_TYPES = new Set([
27
27
  'bulletList',
28
28
  'orderedList',
29
29
  'image',
30
+ 'imageGallery',
30
31
  'videoEmbed',
31
32
  'table',
32
33
  ])
@@ -94,6 +95,7 @@ const NODE_ATTRIBUTE_NAMES = Object.freeze({
94
95
  orderedList: ['start', 'type'],
95
96
  listItem: [],
96
97
  image: ['src', 'alt', 'title', 'width', 'height'],
98
+ imageGallery: ['images'],
97
99
  videoEmbed: ['src', 'title', 'descriptiveText'],
98
100
  table: ['caption'],
99
101
  tableRow: [],
@@ -212,6 +214,18 @@ function isSafeResource(value, allowHash = false) {
212
214
  }
213
215
  }
214
216
 
217
+ function isSafeImageResource(value) {
218
+ if (typeof value !== 'string' || !value.trim()) return false
219
+ if (value.trim().startsWith('#')) return false
220
+
221
+ try {
222
+ const url = new URL(value.trim(), 'https://ui-next.invalid')
223
+ return url.protocol === 'http:' || url.protocol === 'https:'
224
+ } catch (_error) {
225
+ return false
226
+ }
227
+ }
228
+
215
229
  function isCanonicalVideoEmbedSource(value) {
216
230
  if (typeof value !== 'string' || !value) return false
217
231
 
@@ -328,6 +342,33 @@ function validateAtom(node, path) {
328
342
  return null
329
343
  }
330
344
 
345
+ function validateImageAttributes(node, path, requiredSource = false) {
346
+ const srcIssue = validateStringAttribute(
347
+ node,
348
+ 'src',
349
+ path,
350
+ requiredSource
351
+ )
352
+ if (srcIssue || (node.attrs?.src && !isSafeImageResource(node.attrs.src))) {
353
+ return srcIssue || issue('unsafe-resource', path, 'src')
354
+ }
355
+ for (const name of ['alt', 'title']) {
356
+ const attrIssue = validateStringAttribute(node, name, path)
357
+ if (attrIssue) return attrIssue
358
+ }
359
+ for (const name of ['width', 'height']) {
360
+ const value = node.attrs?.[name]
361
+ if (
362
+ value !== undefined &&
363
+ value !== null &&
364
+ (!Number.isFinite(value) || value <= 0)
365
+ ) {
366
+ return issue('malformed-attribute', path, name)
367
+ }
368
+ }
369
+ return null
370
+ }
371
+
331
372
  function validateNode(node, path) {
332
373
  if (!isRecord(node) || typeof node.type !== 'string') {
333
374
  return issue('malformed-node', path)
@@ -430,25 +471,27 @@ function validateNode(node, path) {
430
471
  : issue('invalid-child-node', `${path}.content[0]`, node.content[0].type)
431
472
  }
432
473
  if (node.type === 'image') {
433
- const srcIssue = validateStringAttribute(node, 'src', path)
434
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
435
- return srcIssue || issue('unsafe-resource', path, 'src')
436
- }
437
- for (const name of ['alt', 'title']) {
438
- const attrIssue = validateStringAttribute(node, name, path)
439
- if (attrIssue) return attrIssue
440
- }
441
- for (const name of ['width', 'height']) {
442
- const value = node.attrs?.[name]
443
- if (
444
- value !== undefined &&
445
- value !== null &&
446
- (!Number.isFinite(value) || value <= 0)
447
- ) {
448
- return issue('malformed-attribute', path, name)
474
+ return validateImageAttributes(node, path) || validateAtom(node, path)
475
+ }
476
+ if (node.type === 'imageGallery') {
477
+ const atomIssue = validateAtom(node, path)
478
+ if (atomIssue) return atomIssue
479
+ const images = node.attrs?.images
480
+ if (!Array.isArray(images) || images.length < 2 || images.length > 3) {
481
+ return issue('malformed-attribute', path, 'images')
482
+ }
483
+ for (let index = 0; index < images.length; index += 1) {
484
+ const imagePath = `${path}.attrs.images[${index}]`
485
+ if (!isRecord(images[index])) {
486
+ return issue('malformed-attribute', imagePath, 'image')
449
487
  }
488
+ const imageNode = { type: 'image', attrs: images[index] }
489
+ const attributeNameIssue = validateAttributeNames(imageNode, imagePath)
490
+ if (attributeNameIssue) return attributeNameIssue
491
+ const imageIssue = validateImageAttributes(imageNode, imagePath, true)
492
+ if (imageIssue) return imageIssue
450
493
  }
451
- return validateAtom(node, path)
494
+ return null
452
495
  }
453
496
  if (node.type === 'videoEmbed') {
454
497
  const srcIssue = validateStringAttribute(node, 'src', path, true)
@@ -692,6 +735,17 @@ function renderTable(node) {
692
735
  )}</tbody></table></div>`
693
736
  }
694
737
 
738
+ function renderImage(attrs) {
739
+ return `<img${attribute('src', attrs.src)}${attribute(
740
+ 'alt',
741
+ attrs.alt,
742
+ true
743
+ )}${attribute('title', attrs.title)}${attribute(
744
+ 'width',
745
+ attrs.width
746
+ )}${attribute('height', attrs.height)}>`
747
+ }
748
+
695
749
  function renderStandardNode(node) {
696
750
  if (node.type === 'text') return renderMarkedText(node)
697
751
  if (node.type === 'hardBreak') return '<br>'
@@ -721,14 +775,12 @@ function renderStandardNode(node) {
721
775
  }
722
776
  if (node.type === 'listItem') return `<li>${renderChildren(node)}</li>`
723
777
  if (node.type === 'image') {
724
- return `<img${attribute('src', node.attrs.src)}${attribute(
725
- 'alt',
726
- node.attrs.alt,
727
- true
728
- )}${attribute('title', node.attrs.title)}${attribute(
729
- 'width',
730
- node.attrs.width
731
- )}${attribute('height', node.attrs.height)}>`
778
+ return renderImage(node.attrs)
779
+ }
780
+ if (node.type === 'imageGallery') {
781
+ return `<figure class="asd20-message-image-gallery" data-cc-node="image-gallery"><div class="asd20-message-image-gallery__images" data-cc-gallery-images="">${node.attrs.images
782
+ .map(renderImage)
783
+ .join('')}</div></figure>`
732
784
  }
733
785
  if (node.type === 'videoEmbed') {
734
786
  return `<div class="asd20-message-video" data-cc-node="video"${attribute(