@bendyline/squisq-react 1.0.3 → 1.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -52,17 +52,17 @@
52
52
  "react-dom": "^18.0.0 || ^19.0.0"
53
53
  },
54
54
  "dependencies": {
55
- "@bendyline/squisq": "1.1.1"
55
+ "@bendyline/squisq": "1.2.1"
56
56
  },
57
57
  "devDependencies": {
58
- "@types/react": "^18.0.0",
59
- "preact": "^10.25.0",
60
- "react": "^18.0.0",
61
- "react-dom": "^18.0.0",
62
- "@testing-library/react": "^16.0.0",
63
- "@testing-library/jest-dom": "^6.0.0",
64
- "jsdom": "^25.0.0",
65
- "tsup": "^8.0.0",
66
- "typescript": "^5.3.0"
58
+ "@types/react": "18.3.28",
59
+ "preact": "10.29.0",
60
+ "react": "18.3.1",
61
+ "react-dom": "18.3.1",
62
+ "@testing-library/react": "16.3.2",
63
+ "@testing-library/jest-dom": "6.9.1",
64
+ "jsdom": "25.0.1",
65
+ "tsup": "8.5.1",
66
+ "typescript": "5.9.3"
67
67
  }
68
68
  }
@@ -12,6 +12,7 @@ import { TextLayer } from './layers/TextLayer';
12
12
  import { ShapeLayer } from './layers/ShapeLayer';
13
13
  import { MapLayer } from './layers/MapLayer';
14
14
  import { VideoLayer } from './layers/VideoLayer';
15
+ import { TableLayer } from './layers/TableLayer';
15
16
  import { getTransitionClass } from './utils/animationUtils';
16
17
 
17
18
  /** Default viewport dimensions (1080p landscape) - for backwards compatibility */
@@ -137,6 +138,8 @@ function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: Laye
137
138
  isPlaying={isPlaying}
138
139
  />
139
140
  );
141
+ case 'table':
142
+ return <TableLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
140
143
  default:
141
144
  console.warn(`Unknown layer type: ${(layer as Layer).type}`);
142
145
  return null;
@@ -25,7 +25,7 @@ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
25
25
  import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
26
26
  import type { RenderContext } from '@bendyline/squisq/doc';
27
27
  import { extractPlainText } from '@bendyline/squisq/markdown';
28
- import type { MarkdownBlockNode, MarkdownList } from '@bendyline/squisq/markdown';
28
+ import type { MarkdownBlockNode, MarkdownList, MarkdownTable } from '@bendyline/squisq/markdown';
29
29
  import { BlockRenderer } from './BlockRenderer';
30
30
  import { MarkdownRenderer } from './MarkdownRenderer';
31
31
 
@@ -216,6 +216,26 @@ function extractListItems(contents?: MarkdownBlockNode[]): string[] {
216
216
  return items;
217
217
  }
218
218
 
219
+ /** Extract table data (headers, rows, alignment) from block contents. */
220
+ function extractTableData(contents?: MarkdownBlockNode[]): {
221
+ headers: string[];
222
+ rows: string[][];
223
+ align?: (('left' | 'right' | 'center') | null)[];
224
+ } | null {
225
+ if (!contents) return null;
226
+ for (const node of contents) {
227
+ if (node.type === 'table') {
228
+ const table = node as MarkdownTable;
229
+ const [headerRow, ...bodyRows] = table.children;
230
+ if (!headerRow) return null;
231
+ const headers = headerRow.children.map((cell) => extractPlainText(cell).trim());
232
+ const rows = bodyRows.map((row) => row.children.map((cell) => extractPlainText(cell).trim()));
233
+ return { headers, rows, align: table.align };
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
219
239
  /**
220
240
  * Provide sensible default fields for templates that require more than
221
241
  * just a `title`. Prevents crashes from undefined required fields.
@@ -243,6 +263,10 @@ function getTemplateDefaults(
243
263
  return { term: headingText, definition: bodyText || headingText };
244
264
  case 'dateEvent':
245
265
  return { date: headingText, description: bodyText || headingText };
266
+ case 'dataTable': {
267
+ const tableData = extractTableData(contents);
268
+ return tableData ?? { headers: ['Column'], rows: [['Data']] };
269
+ }
246
270
  default:
247
271
  return {};
248
272
  }
@@ -281,7 +305,7 @@ export function LinearDocView({
281
305
  [activeViewport, totalBlocks, theme],
282
306
  );
283
307
 
284
- const activeTheme = theme ?? DEFAULT_THEME;
308
+ const activeTheme = renderContext.theme!;
285
309
  const bgColor = activeTheme.colors.background;
286
310
  const textColor = activeTheme.colors.text;
287
311
  const mutedColor = activeTheme.colors.textMuted;
@@ -388,6 +412,27 @@ export function LinearDocView({
388
412
  .squisq-linear-content em {
389
413
  font-style: italic;
390
414
  }
415
+ .squisq-linear-content table {
416
+ width: 100%;
417
+ border-collapse: collapse;
418
+ margin: 1em 0;
419
+ font-size: 0.95em;
420
+ }
421
+ .squisq-linear-content thead th {
422
+ background: var(--squisq-linear-primary);
423
+ color: var(--squisq-linear-bg);
424
+ font-family: var(--squisq-linear-title-font);
425
+ font-weight: 600;
426
+ padding: 10px 14px;
427
+ text-align: left;
428
+ }
429
+ .squisq-linear-content tbody td {
430
+ padding: 8px 14px;
431
+ border-bottom: 1px solid color-mix(in srgb, var(--squisq-linear-muted) 30%, transparent);
432
+ }
433
+ .squisq-linear-content tbody tr:hover {
434
+ background: color-mix(in srgb, var(--squisq-linear-primary) 8%, transparent);
435
+ }
391
436
  `}</style>
392
437
  {doc.blocks.map((block, i) => (
393
438
  <BlockSection
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { TableLayer } from '../layers/TableLayer';
4
+ import type { TableLayer as TableLayerType } from '@bendyline/squisq/schemas';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function makeTableLayer(overrides: Partial<TableLayerType> = {}): TableLayerType {
11
+ return {
12
+ type: 'table',
13
+ id: 'test-table',
14
+ content: {
15
+ headers: ['Name', 'Age', 'City'],
16
+ rows: [
17
+ ['Alice', '30', 'Seattle'],
18
+ ['Bob', '25', 'Portland'],
19
+ ],
20
+ style: {
21
+ headerBackground: '#1e3a5f',
22
+ headerColor: '#ffffff',
23
+ cellBackground: 'rgba(255,255,255,0.05)',
24
+ cellColor: '#e0e0e0',
25
+ borderColor: 'rgba(255,255,255,0.12)',
26
+ fontSize: 28,
27
+ fontFamily: 'system-ui',
28
+ headerFontFamily: 'Georgia',
29
+ borderRadius: 8,
30
+ },
31
+ },
32
+ position: { x: '10%', y: '10%', width: '80%', height: '80%' },
33
+ ...overrides,
34
+ } as TableLayerType;
35
+ }
36
+
37
+ const viewport = { width: 1920, height: 1080 };
38
+
39
+ function renderTableLayer(layer?: TableLayerType) {
40
+ return render(
41
+ <svg>
42
+ <TableLayer layer={layer ?? makeTableLayer()} viewport={viewport} blockTime={0} />
43
+ </svg>,
44
+ );
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('TableLayer', () => {
52
+ it('renders a foreignObject element', () => {
53
+ const { container } = renderTableLayer();
54
+ const fo = container.querySelector('foreignObject');
55
+ expect(fo).toBeTruthy();
56
+ });
57
+
58
+ it('renders a table element', () => {
59
+ const { container } = renderTableLayer();
60
+ const table = container.querySelector('table');
61
+ expect(table).toBeTruthy();
62
+ });
63
+
64
+ it('renders the correct number of header cells', () => {
65
+ const { container } = renderTableLayer();
66
+ const headers = container.querySelectorAll('th');
67
+ expect(headers.length).toBe(3);
68
+ });
69
+
70
+ it('renders header text content', () => {
71
+ const { container } = renderTableLayer();
72
+ const headers = container.querySelectorAll('th');
73
+ expect(headers[0].textContent).toBe('Name');
74
+ expect(headers[1].textContent).toBe('Age');
75
+ expect(headers[2].textContent).toBe('City');
76
+ });
77
+
78
+ it('renders the correct number of data rows', () => {
79
+ const { container } = renderTableLayer();
80
+ const rows = container.querySelectorAll('tbody tr');
81
+ expect(rows.length).toBe(2);
82
+ });
83
+
84
+ it('renders cell text content', () => {
85
+ const { container } = renderTableLayer();
86
+ const cells = container.querySelectorAll('td');
87
+ expect(cells[0].textContent).toBe('Alice');
88
+ expect(cells[1].textContent).toBe('30');
89
+ expect(cells[2].textContent).toBe('Seattle');
90
+ expect(cells[3].textContent).toBe('Bob');
91
+ });
92
+
93
+ it('applies header background color', () => {
94
+ const { container } = renderTableLayer();
95
+ const th = container.querySelector('th');
96
+ expect(th?.style.background).toBe('rgb(30, 58, 95)');
97
+ });
98
+
99
+ it('applies font size from style', () => {
100
+ const { container } = renderTableLayer();
101
+ const table = container.querySelector('table');
102
+ expect(table?.style.fontSize).toBe('28px');
103
+ });
104
+
105
+ it('applies column alignment when provided', () => {
106
+ const layer = makeTableLayer();
107
+ layer.content.align = ['left', 'center', 'right'];
108
+ const { container } = renderTableLayer(layer);
109
+
110
+ const headers = container.querySelectorAll('th');
111
+ expect(headers[0].style.textAlign).toBe('left');
112
+ expect(headers[1].style.textAlign).toBe('center');
113
+ expect(headers[2].style.textAlign).toBe('right');
114
+ });
115
+
116
+ it('handles empty headers gracefully', () => {
117
+ const layer = makeTableLayer();
118
+ layer.content.headers = [];
119
+ const { container } = renderTableLayer(layer);
120
+ const thead = container.querySelector('thead');
121
+ // No thead or empty thead
122
+ expect(thead).toBeFalsy();
123
+ });
124
+
125
+ it('handles empty rows gracefully', () => {
126
+ const layer = makeTableLayer();
127
+ layer.content.rows = [];
128
+ const { container } = renderTableLayer(layer);
129
+ const tbody = container.querySelector('tbody');
130
+ // No tbody or empty tbody
131
+ expect(tbody).toBeFalsy();
132
+ });
133
+
134
+ it('sets foreignObject dimensions based on viewport percentage', () => {
135
+ const { container } = renderTableLayer();
136
+ const fo = container.querySelector('foreignObject');
137
+ // 80% of 1920 = 1536
138
+ expect(fo?.getAttribute('width')).toBe('1536');
139
+ // 80% of 1080 = 864
140
+ expect(fo?.getAttribute('height')).toBe('864');
141
+ });
142
+ });
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export { ImageLayer } from './layers/ImageLayer.js';
17
17
  export { TextLayer } from './layers/TextLayer.js';
18
18
  export { ShapeLayer } from './layers/ShapeLayer.js';
19
19
  export { VideoLayer } from './layers/VideoLayer.js';
20
+ export { TableLayer } from './layers/TableLayer.js';
20
21
  export { MapLayer } from './layers/MapLayer.js';
21
22
 
22
23
  // Hooks
@@ -0,0 +1,129 @@
1
+ /**
2
+ * TableLayer Component
3
+ *
4
+ * Renders a data table within an SVG block using a <foreignObject> to embed
5
+ * an HTML table. This gives us native table layout inside SVG viewports.
6
+ *
7
+ * The table is styled inline using the TableLayerStyle properties, which are
8
+ * typically derived from the active theme by the dataTable template.
9
+ */
10
+
11
+ import type { TableLayer as TableLayerType } from '@bendyline/squisq/schemas';
12
+ import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
13
+ import { getAnimationStyle } from '../utils/animationUtils';
14
+
15
+ interface TableLayerProps {
16
+ layer: TableLayerType;
17
+ /** Viewport dimensions for percentage calculations */
18
+ viewport: { width: number; height: number };
19
+ /** Current time relative to block start (for animation) */
20
+ blockTime: number;
21
+ }
22
+
23
+ export function TableLayer({ layer, viewport, blockTime }: TableLayerProps) {
24
+ const { content, position, animation } = layer;
25
+ const { headers, rows, align, style } = content;
26
+
27
+ // Resolve position values to pixels
28
+ const x = resolveValue(position.x, viewport.width);
29
+ const y = resolveValue(position.y, viewport.height);
30
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
31
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
32
+
33
+ // Apply anchor offset
34
+ const offset = getAnchorOffset(position.anchor, width, height);
35
+ const finalX = x + offset.x;
36
+ const finalY = y + offset.y;
37
+
38
+ // Build animation style
39
+ const animStyle = animation ? getAnimationStyle(animation, blockTime) : {};
40
+
41
+ const cellAlign = (ci: number): React.CSSProperties | undefined => {
42
+ const a = align?.[ci];
43
+ return a ? { textAlign: a } : undefined;
44
+ };
45
+
46
+ const borderRadius = style.borderRadius ?? 8;
47
+
48
+ return (
49
+ <foreignObject x={finalX} y={finalY} width={width} height={height} style={animStyle}>
50
+ <div
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}
53
+ style={{
54
+ width: '100%',
55
+ height: '100%',
56
+ display: 'flex',
57
+ alignItems: 'center',
58
+ justifyContent: 'center',
59
+ padding: '16px',
60
+ boxSizing: 'border-box',
61
+ }}
62
+ >
63
+ <table
64
+ style={{
65
+ width: '100%',
66
+ borderCollapse: 'separate',
67
+ borderSpacing: 0,
68
+ fontSize: `${style.fontSize}px`,
69
+ fontFamily: style.fontFamily ?? 'system-ui, sans-serif',
70
+ overflow: 'hidden',
71
+ borderRadius: `${borderRadius}px`,
72
+ border: `1px solid ${style.borderColor}`,
73
+ }}
74
+ >
75
+ {headers.length > 0 && (
76
+ <thead>
77
+ <tr>
78
+ {headers.map((header, ci) => (
79
+ <th
80
+ key={ci}
81
+ style={{
82
+ background: style.headerBackground,
83
+ color: style.headerColor,
84
+ fontFamily:
85
+ style.headerFontFamily ?? style.fontFamily ?? 'system-ui, sans-serif',
86
+ fontWeight: 600,
87
+ padding: '12px 16px',
88
+ borderBottom: `2px solid ${style.borderColor}`,
89
+ borderRight:
90
+ ci < headers.length - 1 ? `1px solid ${style.borderColor}` : undefined,
91
+ ...cellAlign(ci),
92
+ }}
93
+ >
94
+ {header}
95
+ </th>
96
+ ))}
97
+ </tr>
98
+ </thead>
99
+ )}
100
+ {rows.length > 0 && (
101
+ <tbody>
102
+ {rows.map((row, ri) => (
103
+ <tr key={ri}>
104
+ {row.map((cell, ci) => (
105
+ <td
106
+ key={ci}
107
+ style={{
108
+ background: style.cellBackground,
109
+ color: style.cellColor,
110
+ padding: '10px 16px',
111
+ borderBottom:
112
+ ri < rows.length - 1 ? `1px solid ${style.borderColor}` : undefined,
113
+ borderRight:
114
+ ci < row.length - 1 ? `1px solid ${style.borderColor}` : undefined,
115
+ ...cellAlign(ci),
116
+ }}
117
+ >
118
+ {cell}
119
+ </td>
120
+ ))}
121
+ </tr>
122
+ ))}
123
+ </tbody>
124
+ )}
125
+ </table>
126
+ </div>
127
+ </foreignObject>
128
+ );
129
+ }