@asd20/ui-next 2.15.1 → 2.17.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,19 @@
1
1
  # Changelog
2
2
 
3
+ # [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
+
5
+
6
+ ### Features
7
+
8
+ * add canonical video rendering ([295fbfd](https://github.com/academydistrict20/asd20-ui-next/commit/295fbfd38d3d66c2bc1e0070fb98a15ba6e51d02))
9
+
10
+ # [2.16.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.15.1...ui-next-v2.16.0) (2026-08-04)
11
+
12
+
13
+ ### Features
14
+
15
+ * support canonical TipTap tables ([179eca3](https://github.com/academydistrict20/asd20-ui-next/commit/179eca3f7c0e0f2f226c8d660d84e3564fbf28c2))
16
+
3
17
  ## [2.15.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.15.0...ui-next-v2.15.1) (2026-07-31)
4
18
 
5
19
  # [2.15.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.14.1...ui-next-v2.15.0) (2026-07-31)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.15.1",
3
+ "version": "2.17.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -114,5 +114,73 @@ export default {
114
114
  &__html {
115
115
  display: block;
116
116
  }
117
+
118
+ :deep(.tableWrapper) {
119
+ max-width: 100%;
120
+ margin: 0.75rem 0;
121
+ overflow-x: auto;
122
+ overscroll-behavior-inline: contain;
123
+ }
124
+
125
+ :deep(.asd20-message-table) {
126
+ border-collapse: collapse;
127
+ font-size: 0.875rem;
128
+ }
129
+
130
+ :deep(.asd20-message-table th),
131
+ :deep(.asd20-message-table td) {
132
+ min-width: 3rem;
133
+ padding: 0.5rem;
134
+ border-bottom: 1px solid #cfd8e3;
135
+ vertical-align: top;
136
+ }
137
+
138
+ :deep(.asd20-message-table th) {
139
+ color: white;
140
+ background: #003b71;
141
+ font-weight: 600;
142
+ }
143
+
144
+ :deep(.asd20-message-table caption) {
145
+ padding: 0 0 0.5rem;
146
+ color: #263238;
147
+ font-weight: 600;
148
+ text-align: left;
149
+ }
150
+
151
+ :deep(.asd20-message-table th > :first-child),
152
+ :deep(.asd20-message-table td > :first-child) {
153
+ margin-top: 0;
154
+ }
155
+
156
+ :deep(.asd20-message-table th > :last-child),
157
+ :deep(.asd20-message-table td > :last-child) {
158
+ margin-bottom: 0;
159
+ }
160
+
161
+ :deep(.asd20-message-video) {
162
+ max-width: 100%;
163
+ margin: 0.75rem 0;
164
+ }
165
+
166
+ :deep(.asd20-message-video__description) {
167
+ margin: 0 0 0.5rem;
168
+ }
169
+
170
+ :deep(.asd20-message-video__frame) {
171
+ position: relative;
172
+ width: 100%;
173
+ overflow: hidden;
174
+ aspect-ratio: 16 / 9;
175
+ background: #000;
176
+ }
177
+
178
+ :deep(.asd20-message-video__frame iframe) {
179
+ position: absolute;
180
+ inset: 0;
181
+ width: 100%;
182
+ height: 100%;
183
+ border: 0;
184
+ }
117
185
  }
118
186
  </style>
@@ -55,6 +55,23 @@ const MARK_TYPES = new Set([
55
55
  'subscript',
56
56
  'superscript',
57
57
  ])
58
+ const TABLE_HEADER_SCOPES = new Set(['col', 'row', 'colgroup', 'rowgroup'])
59
+ const VIDEO_TITLE_MAX_LENGTH = 200
60
+ const VIDEO_DESCRIPTION_MAX_LENGTH = 1000
61
+ const VIDEO_PROVIDER_RULES = Object.freeze([
62
+ {
63
+ host: 'www.youtube.com',
64
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
65
+ },
66
+ {
67
+ host: 'www.youtube-nocookie.com',
68
+ pathPattern: /^\/embed\/([A-Za-z0-9_-]{11})$/,
69
+ },
70
+ {
71
+ host: 'player.vimeo.com',
72
+ pathPattern: /^\/video\/([0-9]+)$/,
73
+ },
74
+ ])
58
75
  const MARK_RENDER_ORDER = Object.freeze({
59
76
  link: 0,
60
77
  bold: 1,
@@ -78,10 +95,10 @@ const NODE_ATTRIBUTE_NAMES = Object.freeze({
78
95
  listItem: [],
79
96
  image: ['src', 'alt', 'title', 'width', 'height'],
80
97
  videoEmbed: ['src', 'title', 'descriptiveText'],
81
- table: [],
98
+ table: ['caption'],
82
99
  tableRow: [],
83
100
  tableCell: ['colspan', 'rowspan', 'colwidth', 'align'],
84
- tableHeader: ['colspan', 'rowspan', 'colwidth', 'align'],
101
+ tableHeader: ['colspan', 'rowspan', 'colwidth', 'align', 'scope'],
85
102
  tableauBlock: [
86
103
  'blockId',
87
104
  'src',
@@ -195,6 +212,45 @@ function isSafeResource(value, allowHash = false) {
195
212
  }
196
213
  }
197
214
 
215
+ function isCanonicalVideoEmbedSource(value) {
216
+ if (typeof value !== 'string' || !value) return false
217
+
218
+ try {
219
+ const url = new URL(value)
220
+ if (
221
+ url.protocol !== 'https:' ||
222
+ url.username ||
223
+ url.password ||
224
+ url.port ||
225
+ url.search ||
226
+ url.hash
227
+ ) {
228
+ return false
229
+ }
230
+
231
+ const rule = VIDEO_PROVIDER_RULES.find(
232
+ candidate => candidate.host === url.hostname.toLowerCase()
233
+ )
234
+ return Boolean(
235
+ rule &&
236
+ rule.pathPattern.test(url.pathname) &&
237
+ value === `https://${rule.host}${url.pathname}`
238
+ )
239
+ } catch (_error) {
240
+ return false
241
+ }
242
+ }
243
+
244
+ function validateRequiredCanonicalText(node, name, path, maximumLength) {
245
+ const attrIssue = validateStringAttribute(node, name, path, true)
246
+ if (attrIssue) return attrIssue
247
+
248
+ const value = node.attrs[name]
249
+ return !value || value !== value.trim() || value.length > maximumLength
250
+ ? issue('malformed-attribute', path, name)
251
+ : null
252
+ }
253
+
198
254
  function validateMarks(marks, path) {
199
255
  if (marks === undefined) return null
200
256
  if (!Array.isArray(marks)) return issue('malformed-marks', path)
@@ -395,17 +451,36 @@ function validateNode(node, path) {
395
451
  return validateAtom(node, path)
396
452
  }
397
453
  if (node.type === 'videoEmbed') {
398
- const srcIssue = validateStringAttribute(node, 'src', path)
399
- if (srcIssue || (node.attrs?.src && !isSafeResource(node.attrs.src))) {
454
+ const srcIssue = validateStringAttribute(node, 'src', path, true)
455
+ if (srcIssue || !isCanonicalVideoEmbedSource(node.attrs?.src)) {
400
456
  return srcIssue || issue('unsafe-resource', path, 'src')
401
457
  }
402
- for (const name of ['title', 'descriptiveText']) {
403
- const attrIssue = validateStringAttribute(node, name, path)
404
- if (attrIssue) return attrIssue
405
- }
458
+ const titleIssue = validateRequiredCanonicalText(
459
+ node,
460
+ 'title',
461
+ path,
462
+ VIDEO_TITLE_MAX_LENGTH
463
+ )
464
+ if (titleIssue) return titleIssue
465
+ const descriptionIssue = validateRequiredCanonicalText(
466
+ node,
467
+ 'descriptiveText',
468
+ path,
469
+ VIDEO_DESCRIPTION_MAX_LENGTH
470
+ )
471
+ if (descriptionIssue) return descriptionIssue
406
472
  return validateAtom(node, path)
407
473
  }
408
474
  if (node.type === 'table') {
475
+ const captionIssue = validateStringAttribute(node, 'caption', path)
476
+ if (captionIssue) return captionIssue
477
+ if (
478
+ node.attrs?.caption !== undefined &&
479
+ node.attrs?.caption !== null &&
480
+ !node.attrs.caption.trim()
481
+ ) {
482
+ return issue('malformed-attribute', path, 'caption')
483
+ }
409
484
  return validateChildren(node, path, new Set(['tableRow']), true)
410
485
  }
411
486
  if (node.type === 'tableRow') {
@@ -421,6 +496,7 @@ function validateNode(node, path) {
421
496
  const rowspan = node.attrs?.rowspan ?? 1
422
497
  const colwidth = node.attrs?.colwidth
423
498
  const align = node.attrs?.align
499
+ const scope = node.attrs?.scope
424
500
  if (!Number.isInteger(colspan) || colspan < 1) {
425
501
  return issue('malformed-attribute', path, 'colspan')
426
502
  }
@@ -443,6 +519,14 @@ function validateNode(node, path) {
443
519
  ) {
444
520
  return issue('malformed-attribute', path, 'align')
445
521
  }
522
+ if (
523
+ node.type === 'tableHeader' &&
524
+ scope !== undefined &&
525
+ scope !== null &&
526
+ !TABLE_HEADER_SCOPES.has(scope)
527
+ ) {
528
+ return issue('malformed-attribute', path, 'scope')
529
+ }
446
530
  return validateChildren(node, path, STANDARD_BLOCK_NODE_TYPES, true)
447
531
  }
448
532
  if (node.type === 'tableauBlock') {
@@ -563,14 +647,15 @@ function renderTableCell(node, tagName) {
563
647
  const rowspan = node.attrs?.rowspan ?? 1
564
648
  const colwidth = node.attrs?.colwidth
565
649
  const align = node.attrs?.align
650
+ const scope = node.attrs?.scope
566
651
  return `<${tagName}${attribute('colspan', colspan)}${attribute(
567
652
  'rowspan',
568
653
  rowspan
569
654
  )}${
570
655
  Array.isArray(colwidth) ? attribute('colwidth', colwidth.join(',')) : ''
571
- }${align ? attribute('style', `text-align: ${align}`) : ''}>${renderChildren(
572
- node
573
- )}</${tagName}>`
656
+ }${align ? attribute('style', `text-align: ${align}`) : ''}${
657
+ scope ? attribute('scope', scope) : ''
658
+ }>${renderChildren(node)}</${tagName}>`
574
659
  }
575
660
 
576
661
  function renderTable(node) {
@@ -596,12 +681,15 @@ function renderTable(node) {
596
681
  : `<col style="min-width: ${minimumWidth}px;">`
597
682
  )
598
683
  .join('')
599
- return `<table${attribute(
684
+ const caption = node.attrs?.caption
685
+ ? `<caption>${escapeHtml(node.attrs.caption)}</caption>`
686
+ : ''
687
+ return `<div class="tableWrapper"><table class="asd20-message-table"${attribute(
600
688
  'style',
601
689
  tableStyle
602
- )}><colgroup>${colgroup}</colgroup><tbody>${renderChildren(
690
+ )}>${caption}<colgroup>${colgroup}</colgroup><tbody>${renderChildren(
603
691
  node
604
- )}</tbody></table>`
692
+ )}</tbody></table></div>`
605
693
  }
606
694
 
607
695
  function renderStandardNode(node) {
@@ -643,16 +731,25 @@ function renderStandardNode(node) {
643
731
  )}${attribute('height', node.attrs.height)}>`
644
732
  }
645
733
  if (node.type === 'videoEmbed') {
646
- return `<div data-cc-node="video"${attribute(
734
+ return `<div class="asd20-message-video" data-cc-node="video"${attribute(
647
735
  'data-src',
648
736
  node.attrs.src
649
737
  )}${attribute('data-title', node.attrs.title)}${attribute(
650
738
  'data-descriptive-text',
651
739
  node.attrs.descriptiveText
652
- )}><iframe${attribute('src', node.attrs.src)}${attribute(
740
+ )}><p class="asd20-message-video__description">${escapeHtml(
741
+ node.attrs.descriptiveText
742
+ )}</p><div class="asd20-message-video__frame"><iframe${attribute(
743
+ 'src',
744
+ node.attrs.src
745
+ )}${attribute(
653
746
  'title',
654
- node.attrs.title || 'Embedded video'
655
- )}></iframe></div>`
747
+ node.attrs.title
748
+ )} loading="lazy" referrerpolicy="strict-origin-when-cross-origin"${attribute(
749
+ 'allowfullscreen',
750
+ '',
751
+ true
752
+ )}></iframe></div></div>`
656
753
  }
657
754
  if (node.type === 'table') return renderTable(node)
658
755
  if (node.type === 'tableRow') return `<tr>${renderChildren(node)}</tr>`