juxscript 1.1.185 → 1.1.187

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.
@@ -4,7 +4,9 @@ import { TabularDriver } from '../storage/TabularDriver.js';
4
4
  import { FileUpload } from './fileupload.js';
5
5
  import { Table } from './table.js';
6
6
  import { Tabs } from './tabs.js';
7
+ import { Modal } from './modal.js'; // ✅ Import Modal
7
8
  import { renderIcon } from './icons.js';
9
+ import { button } from './button.js'; // ✅ Import button factory
8
10
 
9
11
  const TRIGGER_EVENTS = [] as const;
10
12
  const CALLBACK_EVENTS = ['load', 'error', 'transform'] as const;
@@ -39,7 +41,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
39
41
  private _df: DataFrame | null = null;
40
42
  private _driver: TabularDriver;
41
43
  private _table: Table | null = null;
42
- private _tabs: Tabs | null = null; // ✅ NEW: Tabs for multi-sheet Excel
44
+ private _tabs: Tabs | null = null; // ✅ NEW: Tabs for multi-sheet Excel // @ts-ignore used for multi-sheet
43
45
  private _sheets: Map<string, DataFrame> = new Map(); // ✅ NEW: Store all sheets
44
46
  private _tableOptions: {
45
47
  striped: boolean;
@@ -49,7 +51,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
49
51
  paginated: boolean;
50
52
  rowsPerPage: number;
51
53
  };
52
- private _uploadRef: FileUpload | null = null;
54
+ private _uploadRef: FileUpload | null = null; // @ts-ignore used for reference tracking
53
55
  private _storageKey: string | null = null;
54
56
  private _pendingSource: (() => Promise<void>) | null = null;
55
57
  private _inlineUpload: { label: string; accept: string; icon: string } | null = null;
@@ -59,7 +61,9 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
59
61
  private _sheetChunkSize: number = 10000; // ✅ Default 10k chunk
60
62
  private _maxFileSize: number = 50; // ✅ Default 50MB
61
63
  private _showReshapeWarning: boolean = true;
62
- private _rawFileData: { file: File; text?: string; isExcel?: boolean } | null = null; // ✅ Add isExcel flag
64
+ private _rawFileData: { file: File; text?: string; isExcel?: boolean } | null = null;
65
+ private _reshapeModal: Modal | null = null; // ✅ ADD THIS LINE
66
+ private _reshapeModalRendered: boolean = false; // Track if modal already rendered
63
67
 
64
68
  constructor(id: string, options: DataFrameOptions = {}) {
65
69
  super(id, {
@@ -454,7 +458,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
454
458
  * INTERNAL
455
459
  * ═══════════════════════════════════════════════════ */
456
460
 
457
- private _updateStatus(text: string, type: 'loading' | 'success' | 'error' | 'empty' = 'empty'): void {
461
+ private _updateStatus(text: string, type: 'loading' | 'success' | 'error' | 'empty' | 'warning' = 'empty'): void {
458
462
  const el = document.getElementById(`${this._id}-status`);
459
463
  if (!el) return;
460
464
 
@@ -463,7 +467,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
463
467
 
464
468
  el.innerHTML = '';
465
469
 
466
- if (this._icon && type === 'success') {
470
+ if (this._icon && (type === 'success' || type === 'warning')) {
467
471
  const iconEl = renderIcon(this._icon);
468
472
  iconEl.style.width = '16px';
469
473
  iconEl.style.height = '16px';
@@ -490,117 +494,116 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
490
494
  this._df = df.select(...cleanCols);
491
495
  }
492
496
 
493
- this._updateTable();
494
- this._updateStatus(
495
- `${sourceName} ${this._df!.height} rows × ${this._df!.width} cols`,
496
- 'success'
497
- );
497
+ // Update the table with new data
498
+ if (this._table && this._df) {
499
+ const columnDefs = this._df.columns.map(col => ({
500
+ key: col,
501
+ label: col
502
+ }));
503
+ this._table.columns(columnDefs).rows(this._df.toRows());
504
+ }
498
505
 
499
- // ✅ FIXED: Add reshape warning button if CSV OR Excel and enabled
500
- if (this._showReshapeWarning && this._rawFileData) {
501
- const statusEl = document.getElementById(`${this._id}-status`);
502
- if (statusEl) {
503
- const settingsBtn = document.createElement('button');
504
- settingsBtn.textContent = 'Settings';
505
- settingsBtn.className = 'jux-button jux-button-sm jux-button-ghost';
506
- settingsBtn.style.marginLeft = '0.5rem';
507
- settingsBtn.addEventListener('click', () => this._showReshapeModal());
508
- statusEl.appendChild(settingsBtn);
506
+ // ✅ Detect malformed data
507
+ const isMalformed = this._detectMalformedData(this._df!);
508
+
509
+ // ✅ Show warning if malformed
510
+ if (isMalformed && this._showReshapeWarning && this._rawFileData) {
511
+ this._updateStatus(
512
+ `⚠️ ${sourceName} — ${this._df!.height} rows × ${this._df!.width} cols (Data may be malformed — headers may be on wrong row)`,
513
+ 'warning'
514
+ );
515
+
516
+ // Add Fix Import Settings button after a tick to ensure status DOM is ready
517
+ requestAnimationFrame(() => {
518
+ const statusEl = document.getElementById(`${this._id}-status`);
519
+ if (statusEl) {
520
+ const settingsBtn = document.createElement('button');
521
+ settingsBtn.textContent = '⚙️ Fix Import Settings';
522
+ settingsBtn.className = 'jux-button jux-button-sm jux-button-warning';
523
+ settingsBtn.style.marginLeft = '0.5rem';
524
+ settingsBtn.addEventListener('click', () => this._showReshapeModal());
525
+ statusEl.appendChild(settingsBtn);
526
+ }
527
+ });
528
+ } else {
529
+ this._updateStatus(
530
+ `${sourceName} — ${this._df!.height} rows × ${this._df!.width} cols`,
531
+ 'success'
532
+ );
533
+
534
+ // ✅ Still add Settings button for manual adjustment
535
+ if (this._showReshapeWarning && this._rawFileData) {
536
+ requestAnimationFrame(() => {
537
+ const statusEl = document.getElementById(`${this._id}-status`);
538
+ if (statusEl) {
539
+ const settingsBtn = document.createElement('button');
540
+ settingsBtn.textContent = '⚙️ Settings';
541
+ settingsBtn.className = 'jux-button jux-button-sm jux-button-ghost';
542
+ settingsBtn.style.marginLeft = '0.5rem';
543
+ settingsBtn.addEventListener('click', () => this._showReshapeModal());
544
+ statusEl.appendChild(settingsBtn);
545
+ }
546
+ });
509
547
  }
510
548
  }
511
549
 
512
550
  this._triggerCallback('load', this._df, null, this);
513
551
  }
514
552
 
515
- private _updateTable(): void {
516
- if (!this._table || !this._df) return;
517
-
518
- // Convert string[] columns to ColumnDef[] with labels
519
- const columnDefs = this._df.columns.map(col => ({
520
- key: col,
521
- label: col
522
- }));
523
-
524
- // ✅ Update columns and rows
525
- this._table.columns(columnDefs).rows(this._df.toRows());
526
-
527
- // ✅ FIX: Force full table rebuild (including pagination)
528
- const tableElement = this._table['_tableElement'];
529
- if (tableElement) {
530
- const wrapper = tableElement.closest('.jux-table-wrapper') as HTMLElement;
531
- if (wrapper) {
532
- // Clear table content
533
- tableElement.innerHTML = '';
534
-
535
- // Rebuild header
536
- const thead = (this._table as any)._buildTableHeader();
537
- tableElement.appendChild(thead);
538
-
539
- // Rebuild body
540
- const tbody = document.createElement('tbody');
541
- (this._table as any)._renderTableBody(tbody);
542
- tableElement.appendChild(tbody);
543
-
544
- // Re-wire events
545
- (this._table as any)._wireTriggerEvents(tbody);
546
-
547
- // ✅ FIX: Re-build pagination controls
548
- if (this._tableOptions.paginated) {
549
- (this._table as any)._updatePagination(wrapper, tbody);
550
- }
551
- }
552
- }
553
- }
553
+ /**
554
+ * ✅ NEW: Detect if data looks malformed
555
+ */
556
+ private _detectMalformedData(df: DataFrame): boolean {
557
+ const columns = df.columns;
558
+ const rows = df.toRows();
559
+
560
+ // Check 1: Columns have generic names like "__EMPTY", "_1", "col_0"
561
+ const hasGenericColumns = columns.some(col =>
562
+ col.startsWith('__EMPTY') ||
563
+ col.startsWith('_') ||
564
+ col.match(/^col_\d+$/)
565
+ );
554
566
 
555
- private _showFilterInput(): void {
556
- const wrapper = document.getElementById(this._id);
557
- if (!wrapper) return;
558
- if (wrapper.querySelector('.jux-dataframe-filter')) return;
567
+ if (hasGenericColumns) return true;
559
568
 
560
- const filterContainer = document.createElement('div');
561
- filterContainer.className = 'jux-dataframe-filter';
569
+ // Check 2: First row values look like metadata (e.g., "Exported On:", "12/4/2025")
570
+ if (rows.length > 0) {
571
+ const firstRow = rows[0];
572
+ const values = Object.values(firstRow);
573
+ const hasMetadata = values.some(v =>
574
+ String(v).includes('Exported') ||
575
+ String(v).includes('Generated') ||
576
+ String(v).includes('Report')
577
+ );
562
578
 
563
- const input = document.createElement('input');
564
- input.type = 'text';
565
- input.placeholder = 'Filter rows...';
566
- input.className = 'jux-input-element jux-dataframe-filter-input';
579
+ if (hasMetadata) return true;
580
+ }
567
581
 
568
- const iconEl = renderIcon('search');
569
- iconEl.style.width = '16px';
570
- iconEl.style.height = '16px';
582
+ // Check 3: Row 2 or 3 has values that look like headers (mostly strings, no numbers)
583
+ if (rows.length >= 3) {
584
+ const secondRow = rows[1];
585
+ const thirdRow = rows[2];
571
586
 
572
- const iconWrap = document.createElement('span');
573
- iconWrap.className = 'jux-dataframe-filter-icon';
574
- iconWrap.appendChild(iconEl);
587
+ const checkRow = (row: Record<string, any>) => {
588
+ const values = Object.values(row);
589
+ const nonNumeric = values.filter(v => {
590
+ const str = String(v).trim();
591
+ return isNaN(Number(str)) && str !== '';
592
+ }).length;
575
593
 
576
- filterContainer.appendChild(iconWrap);
577
- filterContainer.appendChild(input);
594
+ return nonNumeric >= values.length * 0.7; // 70% non-numeric
595
+ };
578
596
 
579
- const tableElement = wrapper.querySelector('.jux-table-wrapper table');
580
- if (tableElement && tableElement.parentElement) {
581
- tableElement.parentElement.insertBefore(filterContainer, tableElement);
582
- } else {
583
- wrapper.appendChild(filterContainer);
597
+ if (checkRow(secondRow) || checkRow(thirdRow)) {
598
+ return true;
599
+ }
584
600
  }
585
601
 
586
- input.addEventListener('input', () => {
587
- if (!this._df) return;
588
- const text = input.value.toLowerCase();
589
- if (!text) {
590
- this._table?.rows(this._df.toRows());
591
- return;
592
- }
593
- const filtered = this._df.filter((row) => {
594
- return Object.values(row).some(v =>
595
- v !== null && v !== undefined && String(v).toLowerCase().includes(text)
596
- );
597
- });
598
- this._table?.rows(filtered.toRows());
599
- });
602
+ return false;
600
603
  }
601
604
 
602
605
  /**
603
- * ✅ UPDATED: Show settings modal for CSV OR Excel reshaping
606
+ * ✅ UPDATED: Show settings modal using Modal component
604
607
  */
605
608
  private _showReshapeModal(): void {
606
609
  if (!this._rawFileData) return;
@@ -608,252 +611,304 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
608
611
  const isExcel = this._rawFileData.isExcel;
609
612
 
610
613
  if (isExcel) {
611
- // ✅ Excel-specific modal: Let user pick header row from sheet
612
614
  this._showExcelReshapeModal();
613
615
  } else {
614
- // ✅ CSV-specific modal: delimiter + header row
615
616
  this._showCSVReshapeModal();
616
617
  }
617
618
  }
618
619
 
619
620
  /**
620
- * ✅ NEW: Excel-specific reshape modal (header row picker)
621
+ * ✅ UPDATED: Excel reshape modal using Modal component
621
622
  */
622
623
  private async _showExcelReshapeModal(): Promise<void> {
623
624
  if (!this._rawFileData?.file) return;
624
625
 
625
- const modal = document.createElement('div');
626
- modal.className = 'jux-modal-overlay';
627
- modal.innerHTML = `
628
- <div class="jux-modal" style="max-width: 800px;">
629
- <div class="jux-modal-header">
630
- <div class="jux-modal-header-title">Excel Import Settings</div>
631
- <button class="jux-modal-close" id="${this._id}-modal-close">×</button>
632
- </div>
633
- <div class="jux-modal-content" style="padding: 1.5rem;">
634
- <div style="margin-bottom: 1rem;">
635
- <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Header Row (0-based)</label>
636
- <input type="number" id="${this._id}-header-row" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
637
- <div style="font-size: 0.875rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;">
638
- ⚠️ Detected: Row 0 has "Exported On:", row 2 has "340B ID". Try setting this to 2.
639
- </div>
640
- </div>
641
-
642
- <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 300px; overflow-y: auto;">
643
- <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Preview (first 10 rows)</div>
644
- <div id="${this._id}-preview" style="font-family: monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--muted-foreground));"></div>
645
- </div>
626
+ // Remove old modal from DOM if it was previously rendered
627
+ if (this._reshapeModal && this._reshapeModalRendered) {
628
+ const oldEl = document.getElementById(`${this._id}-reshape-modal`);
629
+ if (oldEl) oldEl.remove();
630
+ this._reshapeModal = null;
631
+ this._reshapeModalRendered = false;
632
+ }
633
+
634
+ // Create fresh modal
635
+ this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
636
+ title: 'Excel Import Settings',
637
+ size: 'large',
638
+ close: true,
639
+ backdropClose: false
640
+ });
641
+
642
+ // Build modal content
643
+ const modalContent = document.createElement('div');
644
+ modalContent.innerHTML = `
645
+ <div style="margin-bottom: 1rem;">
646
+ <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
647
+ Header Row (0-based)
648
+ </label>
649
+ <input
650
+ type="number"
651
+ id="${this._id}-header-row"
652
+ class="jux-input-element"
653
+ value="0"
654
+ min="0"
655
+ max="50"
656
+ style="width: 100%;"
657
+ />
658
+ <div style="font-size: 0.875rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem; padding: 0.5rem; background: hsl(var(--warning) / 0.1); border-radius: var(--radius); border: 1px solid hsl(var(--warning) / 0.3);">
659
+ ⚠️ <strong>Detected issue:</strong> Row 0 contains metadata ("Exported On:"), row 2 contains actual headers ("340B ID"). Try setting this to <strong>2</strong>.
646
660
  </div>
647
- <div class="jux-modal-footer">
648
- <button id="${this._id}-cancel-reshape" class="jux-button jux-button-ghost">Cancel</button>
649
- <button id="${this._id}-apply-reshape" class="jux-button">Apply & Re-import</button>
661
+ </div>
662
+
663
+ <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 400px; overflow-y: auto;">
664
+ <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
665
+ Preview (first 10 rows)
650
666
  </div>
667
+ <div id="${this._id}-preview" style="font-family: 'JetBrains Mono', 'Courier New', monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--foreground)); line-height: 1.5;"></div>
651
668
  </div>
652
669
  `;
653
670
 
654
- document.body.appendChild(modal);
671
+ this._reshapeModal
672
+ .content(modalContent.innerHTML)
673
+ .actions([
674
+ {
675
+ label: 'Cancel',
676
+ variant: 'secondary',
677
+ click: () => this._reshapeModal!.closeModal()
678
+ },
679
+ {
680
+ label: 'Apply & Re-import',
681
+ variant: 'primary',
682
+ click: async () => {
683
+ const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
684
+ const headerRow = parseInt(headerRowInput.value) || 0;
685
+
686
+ this.state.loading = true;
687
+ this._updateStatus('⏳ Re-parsing with new settings...', 'loading');
688
+
689
+ try {
690
+ const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
691
+ headerRow,
692
+ maxSheetSize: this._maxSheetSize,
693
+ sheetChunkSize: this._sheetChunkSize
694
+ });
695
+
696
+ const sheetNames = Object.keys(sheets);
697
+ await this._driver.store(this._rawFileData!.file.name, sheets[sheetNames[0]], { source: this._rawFileData!.file.name });
698
+
699
+ if (sheetNames.length > 1) {
700
+ this._renderMultiSheet(sheets, this._rawFileData!.file.name);
701
+ } else {
702
+ this._showReshapeWarning = false; // Prevent recursive warning after manual fix
703
+ this._setDataFrame(sheets[sheetNames[0]], this._rawFileData!.file.name);
704
+ }
705
+
706
+ this._reshapeModal!.closeModal();
707
+ } catch (err: any) {
708
+ this._updateStatus(`❌ ${err.message}`, 'error');
709
+ }
710
+ }
711
+ }
712
+ ]);
713
+
714
+ // Render modal to document.body and open it
715
+ this._reshapeModal.render(document.body);
716
+ this._reshapeModalRendered = true;
717
+
718
+ // Wait a tick for DOM to update after render
719
+ await new Promise(resolve => requestAnimationFrame(resolve));
655
720
 
656
721
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
657
722
  const previewDiv = document.getElementById(`${this._id}-preview`)!;
658
723
 
659
- // Update preview on header row change
724
+ // Update preview on header row change
660
725
  const updatePreview = async () => {
661
- const headerRow = parseInt(headerRowInput.value) || 0;
726
+ const headerRow = parseInt(headerRowInput?.value) || 0;
662
727
 
663
728
  try {
664
- // Re-parse Excel with specified header row
665
729
  const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
666
- headerRow, // ✅ Pass header row to parser
667
- maxSheetSize: 10 // Preview only
730
+ headerRow,
731
+ maxSheetSize: 10
668
732
  });
669
733
 
670
734
  const firstSheet = Object.values(sheets)[0];
671
735
  if (!firstSheet) {
672
- previewDiv.textContent = '⚠️ No data found';
736
+ if (previewDiv) previewDiv.textContent = '⚠️ No data found';
673
737
  return;
674
738
  }
675
739
 
676
740
  const preview = firstSheet.toRows().slice(0, 10).map((row, i) => {
677
- const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' | ');
741
+ const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' ');
678
742
  return `${i === 0 ? '📌 ' : ' '}${cols}`;
679
743
  }).join('\n');
680
744
 
681
- previewDiv.textContent = `Columns: ${firstSheet.columns.join(' | ')}\n\n${preview}`;
745
+ if (previewDiv) previewDiv.textContent = `Columns: ${firstSheet.columns.join(' ')}\n${'─'.repeat(80)}\n${preview}`;
682
746
  } catch (err: any) {
683
- previewDiv.textContent = `⚠️ Error: ${err.message}`;
747
+ if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
684
748
  }
685
749
  };
686
750
 
687
- headerRowInput.addEventListener('input', updatePreview);
751
+ if (headerRowInput) headerRowInput.addEventListener('input', updatePreview);
688
752
  updatePreview();
689
753
 
690
- // ✅ Apply button
691
- document.getElementById(`${this._id}-apply-reshape`)!.addEventListener('click', async () => {
692
- const headerRow = parseInt(headerRowInput.value) || 0;
693
-
694
- this.state.loading = true;
695
- this._updateStatus('⏳ Re-parsing with new settings...', 'loading');
696
-
697
- try {
698
- const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
699
- headerRow,
700
- maxSheetSize: this._maxSheetSize,
701
- sheetChunkSize: this._sheetChunkSize
702
- });
703
-
704
- const sheetNames = Object.keys(sheets);
705
- await this._driver.store(this._rawFileData!.file.name, sheets[sheetNames[0]], { source: this._rawFileData!.file.name });
706
-
707
- if (sheetNames.length > 1) {
708
- this._renderMultiSheet(sheets, this._rawFileData!.file.name);
709
- } else {
710
- this._setDataFrame(sheets[sheetNames[0]], this._rawFileData!.file.name);
711
- }
712
-
713
- modal.remove();
714
- } catch (err: any) {
715
- this._updateStatus(`❌ ${err.message}`, 'error');
716
- }
717
- });
718
-
719
- // ✅ Cancel/Close buttons
720
- const closeModal = () => modal.remove();
721
- document.getElementById(`${this._id}-cancel-reshape`)!.addEventListener('click', closeModal);
722
- document.getElementById(`${this._id}-modal-close`)!.addEventListener('click', closeModal);
754
+ this._reshapeModal.open();
723
755
  }
724
756
 
725
757
  /**
726
- * ✅ CSV-specific reshape modal (existing implementation)
758
+ * ✅ UPDATED: CSV reshape modal using Modal component
727
759
  */
728
760
  private _showCSVReshapeModal(): void {
729
761
  if (!this._rawFileData) return;
730
762
 
731
- const modal = document.createElement('div');
732
- modal.className = 'jux-modal-overlay';
733
- modal.innerHTML = `
734
- <div class="jux-modal" style="max-width: 800px;">
735
- <div class="jux-modal-header">
736
- <div class="jux-modal-header-title">Data Import Settings</div>
737
- <button class="jux-modal-close" id="${this._id}-modal-close">×</button>
738
- </div>
739
- <div class="jux-modal-content" style="padding: 1.5rem;">
740
- <div style="margin-bottom: 1rem;">
741
- <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Delimiter</label>
742
- <select id="${this._id}-delimiter" class="jux-input-element" style="width: 100%;">
743
- <option value=",">Comma (,)</option>
744
- <option value="|">Pipe (|)</option>
745
- <option value="\t">Tab (\\t)</option>
746
- <option value=";">Semicolon (;)</option>
747
- </select>
748
- </div>
749
-
750
- <div style="margin-bottom: 1rem;">
751
- <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Header Row (0-based)</label>
752
- <input type="number" id="${this._id}-header-row" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
753
- </div>
754
-
755
- <div style="margin-bottom: 1rem;">
756
- <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Skip Rows Before Header</label>
757
- <input type="number" id="${this._id}-skip-rows" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
758
- </div>
759
-
760
- <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 300px; overflow-y: auto;">
761
- <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Preview</div>
762
- <div id="${this._id}-preview" style="font-family: monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--muted-foreground));"></div>
763
- </div>
764
- </div>
765
- <div class="jux-modal-footer">
766
- <button id="${this._id}-cancel-reshape" class="jux-button jux-button-ghost">Cancel</button>
767
- <button id="${this._id}-apply-reshape" class="jux-button">Apply & Re-import</button>
768
- </div>
769
- </div>
770
- `;
771
-
772
- document.body.appendChild(modal);
773
-
774
- const delimiterSelect = document.getElementById(`${this._id}-delimiter`) as HTMLSelectElement;
775
- const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
776
- const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
777
- const previewDiv = document.getElementById(`${this._id}-preview`)!;
778
-
779
- // ✅ Auto-detect initial values
780
- if (this._rawFileData.text) {
781
- const detected = (this._driver as any)._detectDelimiter(this._rawFileData.text);
782
- delimiterSelect.value = detected === '\t' ? '\\t' : detected;
783
-
784
- const headerRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
785
- headerRowInput.value = String(headerRow);
763
+ // Remove old modal from DOM if it was previously rendered
764
+ if (this._reshapeModal && this._reshapeModalRendered) {
765
+ const oldEl = document.getElementById(`${this._id}-reshape-modal`);
766
+ if (oldEl) oldEl.remove();
767
+ this._reshapeModal = null;
768
+ this._reshapeModalRendered = false;
786
769
  }
787
770
 
788
- // Update preview on changes
789
- const updatePreview = async () => {
790
- if (!this._rawFileData?.text) return;
771
+ // Create fresh modal
772
+ this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
773
+ title: 'CSV Import Settings',
774
+ size: 'large',
775
+ close: true,
776
+ backdropClose: false
777
+ });
791
778
 
792
- const delim = delimiterSelect.value === '\\t' ? '\t' : delimiterSelect.value;
793
- const headerRow = parseInt(headerRowInput.value) || 0;
794
- const skipRows = parseInt(skipRowsInput.value) || 0;
779
+ // Build modal content
780
+ const modalContent = document.createElement('div');
781
+ modalContent.innerHTML = `
782
+ <div style="margin-bottom: 1rem;">
783
+ <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Delimiter</label>
784
+ <select id="${this._id}-delimiter" class="jux-input-element" style="width: 100%;">
785
+ <option value=",">Comma (,)</option>
786
+ <option value="|">Pipe (|)</option>
787
+ <option value="\t">Tab (\\t)</option>
788
+ <option value=";">Semicolon (;)</option>
789
+ </select>
790
+ </div>
791
+
792
+ <div style="margin-bottom: 1rem;">
793
+ <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Header Row (0-based)</label>
794
+ <input type="number" id="${this._id}-header-row" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
795
+ </div>
796
+
797
+ <div style="margin-bottom: 1rem;">
798
+ <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Skip Rows Before Header</label>
799
+ <input type="number" id="${this._id}-skip-rows" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
800
+ </div>
801
+
802
+ <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 400px; overflow-y: auto;">
803
+ <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Preview</div>
804
+ <div id="${this._id}-preview" style="font-family: 'JetBrains Mono', 'Courier New', monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--foreground)); line-height: 1.5;"></div>
805
+ </div>
806
+ `;
795
807
 
796
- try {
797
- const df = this._driver.parseCSV(this._rawFileData.text, {
798
- delimiter: delim,
799
- headerRow,
800
- skipRows,
801
- hasHeader: true,
802
- maxRows: 10 // Preview first 10 rows
803
- });
808
+ this._reshapeModal
809
+ .content(modalContent.innerHTML)
810
+ .actions([
811
+ {
812
+ label: 'Cancel',
813
+ variant: 'secondary',
814
+ click: () => this._reshapeModal!.closeModal()
815
+ },
816
+ {
817
+ label: 'Apply & Re-import',
818
+ variant: 'primary',
819
+ click: async () => {
820
+ if (!this._rawFileData?.text) return;
821
+
822
+ const delimiterSelect = document.getElementById(`${this._id}-delimiter`) as HTMLSelectElement;
823
+ const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
824
+ const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
825
+
826
+ const delim = delimiterSelect.value === '\\t' ? '\t' : delimiterSelect.value;
827
+ const headerRow = parseInt(headerRowInput.value) || 0;
828
+ const skipRows = parseInt(skipRowsInput.value) || 0;
829
+
830
+ this.state.loading = true;
831
+ this._updateStatus('⏳ Re-parsing with new settings...', 'loading');
832
+
833
+ try {
834
+ const df = this._driver.parseCSV(this._rawFileData.text, {
835
+ delimiter: delim,
836
+ headerRow,
837
+ skipRows,
838
+ hasHeader: true
839
+ });
804
840
 
805
- const preview = df.toRows().map((row, i) => {
806
- const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' | ');
807
- return `${i === 0 ? '📌 ' : ' '}${cols}`;
808
- }).join('\n');
841
+ await this._driver.store(this._rawFileData.file.name, df, { source: this._rawFileData.file.name });
842
+ this._showReshapeWarning = false; // Prevent recursive warning after manual fix
843
+ this._setDataFrame(df, this._rawFileData.file.name);
809
844
 
810
- previewDiv.textContent = `Columns: ${df.columns.join(' | ')}\n\n${preview}`;
811
- } catch (err: any) {
812
- previewDiv.textContent = `⚠️ Error: ${err.message}`;
845
+ this._reshapeModal!.closeModal();
846
+ } catch (err: any) {
847
+ this._updateStatus(`❌ ${err.message}`, 'error');
848
+ }
849
+ }
850
+ }
851
+ ]);
852
+
853
+ // Render modal to document.body and open it
854
+ this._reshapeModal.render(document.body);
855
+ this._reshapeModalRendered = true;
856
+
857
+ // Use requestAnimationFrame to ensure DOM is ready
858
+ requestAnimationFrame(() => {
859
+ const delimiterSelect = document.getElementById(`${this._id}-delimiter`) as HTMLSelectElement;
860
+ const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
861
+ const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
862
+ const previewDiv = document.getElementById(`${this._id}-preview`)!;
863
+
864
+ // Auto-detect initial values
865
+ if (this._rawFileData?.text) {
866
+ const detected = (this._driver as any)._detectDelimiter(this._rawFileData.text);
867
+ if (delimiterSelect) delimiterSelect.value = detected === '\t' ? '\\t' : detected;
868
+
869
+ const headerRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
870
+ if (headerRowInput) headerRowInput.value = String(headerRow);
813
871
  }
814
- };
815
872
 
816
- delimiterSelect.addEventListener('change', updatePreview);
817
- headerRowInput.addEventListener('input', updatePreview);
818
- skipRowsInput.addEventListener('input', updatePreview);
873
+ // Update preview on changes
874
+ const updatePreview = async () => {
875
+ if (!this._rawFileData?.text) return;
819
876
 
820
- updatePreview();
877
+ const delim = delimiterSelect?.value === '\\t' ? '\t' : (delimiterSelect?.value || ',');
878
+ const headerRow = parseInt(headerRowInput?.value) || 0;
879
+ const skipRows = parseInt(skipRowsInput?.value) || 0;
821
880
 
822
- // ✅ Apply button
823
- document.getElementById(`${this._id}-apply-reshape`)!.addEventListener('click', async () => {
824
- if (!this._rawFileData?.text) return;
881
+ try {
882
+ const df = this._driver.parseCSV(this._rawFileData.text, {
883
+ delimiter: delim,
884
+ headerRow,
885
+ skipRows,
886
+ hasHeader: true,
887
+ maxRows: 10
888
+ });
825
889
 
826
- const delim = delimiterSelect.value === '\\t' ? '\t' : delimiterSelect.value;
827
- const headerRow = parseInt(headerRowInput.value) || 0;
828
- const skipRows = parseInt(skipRowsInput.value) || 0;
890
+ const preview = df.toRows().map((row, i) => {
891
+ const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' ');
892
+ return `${i === 0 ? '📌 ' : ' '}${cols}`;
893
+ }).join('\n');
829
894
 
830
- this.state.loading = true;
831
- this._updateStatus('⏳ Re-parsing with new settings...', 'loading');
895
+ if (previewDiv) previewDiv.textContent = `Columns: ${df.columns.join(' │ ')}\n${'─'.repeat(80)}\n${preview}`;
896
+ } catch (err: any) {
897
+ if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
898
+ }
899
+ };
832
900
 
833
- try {
834
- const df = this._driver.parseCSV(this._rawFileData.text, {
835
- delimiter: delim,
836
- headerRow,
837
- skipRows,
838
- hasHeader: true
839
- });
901
+ if (delimiterSelect) delimiterSelect.addEventListener('change', updatePreview);
902
+ if (headerRowInput) headerRowInput.addEventListener('input', updatePreview);
903
+ if (skipRowsInput) skipRowsInput.addEventListener('input', updatePreview);
840
904
 
841
- await this._driver.store(this._rawFileData.file.name, df, { source: this._rawFileData.file.name });
842
- this._setDataFrame(df, this._rawFileData.file.name);
905
+ updatePreview();
843
906
 
844
- modal.remove();
845
- } catch (err: any) {
846
- this._updateStatus(`❌ ${err.message}`, 'error');
847
- }
907
+ this._reshapeModal!.open();
848
908
  });
849
-
850
- // ✅ Cancel/Close buttons
851
- const closeModal = () => modal.remove();
852
- document.getElementById(`${this._id}-cancel-reshape`)!.addEventListener('click', closeModal);
853
- document.getElementById(`${this._id}-modal-close`)!.addEventListener('click', closeModal);
854
909
  }
855
910
 
856
- update(prop: string, value: any): void { }
911
+ update(_prop: string, _value: any): void { }
857
912
 
858
913
  /* ═══════════════════════════════════════════════════
859
914
  * RENDER