@liedekef/ftable 1.1.52 → 1.3.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.
Files changed (38) hide show
  1. package/ftable.esm.js +458 -625
  2. package/ftable.js +458 -625
  3. package/ftable.min.js +2 -2
  4. package/ftable.umd.js +458 -625
  5. package/package.json +1 -1
  6. package/themes/basic/ftable_basic.css +0 -4
  7. package/themes/basic/ftable_basic.min.css +1 -1
  8. package/themes/ftable_theme_base.less +0 -5
  9. package/themes/lightcolor/blue/ftable.css +0 -4
  10. package/themes/lightcolor/blue/ftable.min.css +1 -1
  11. package/themes/lightcolor/gray/ftable.css +0 -4
  12. package/themes/lightcolor/gray/ftable.min.css +1 -1
  13. package/themes/lightcolor/green/ftable.css +0 -4
  14. package/themes/lightcolor/green/ftable.min.css +1 -1
  15. package/themes/lightcolor/orange/ftable.css +0 -4
  16. package/themes/lightcolor/orange/ftable.min.css +1 -1
  17. package/themes/lightcolor/red/ftable.css +0 -4
  18. package/themes/lightcolor/red/ftable.min.css +1 -1
  19. package/themes/metro/blue/ftable.css +0 -4
  20. package/themes/metro/blue/ftable.min.css +1 -1
  21. package/themes/metro/brown/ftable.css +0 -4
  22. package/themes/metro/brown/ftable.min.css +1 -1
  23. package/themes/metro/crimson/ftable.css +0 -4
  24. package/themes/metro/crimson/ftable.min.css +1 -1
  25. package/themes/metro/darkgray/ftable.css +0 -4
  26. package/themes/metro/darkgray/ftable.min.css +1 -1
  27. package/themes/metro/darkorange/ftable.css +0 -4
  28. package/themes/metro/darkorange/ftable.min.css +1 -1
  29. package/themes/metro/green/ftable.css +0 -4
  30. package/themes/metro/green/ftable.min.css +1 -1
  31. package/themes/metro/lightgray/ftable.css +0 -4
  32. package/themes/metro/lightgray/ftable.min.css +1 -1
  33. package/themes/metro/pink/ftable.css +0 -4
  34. package/themes/metro/pink/ftable.min.css +1 -1
  35. package/themes/metro/purple/ftable.css +0 -4
  36. package/themes/metro/purple/ftable.min.css +1 -1
  37. package/themes/metro/red/ftable.css +0 -4
  38. package/themes/metro/red/ftable.min.css +1 -1
package/ftable.umd.js CHANGED
@@ -322,15 +322,13 @@ class FTableHttpClient {
322
322
  }
323
323
 
324
324
  if (Array.isArray(value)) {
325
- // Clean key: remove trailing [] if present
326
- const cleanKey = key.replace(/\[\]$/, '');
327
- const paramKey = cleanKey + '[]'; // Always use [] suffix once
325
+ const keyName = key.endsWith('[]') ? key : key + '[]';
328
326
 
329
327
  // Append each item in the array with the same key
330
328
  // This generates query strings like `key=val1&key=val2&key=val3`
331
329
  value.forEach(item => {
332
330
  if (item !== null && item !== undefined) { // Ensure array items are also not null/undefined
333
- fullUrl.searchParams.append(paramKey, item);
331
+ fullUrl.searchParams.append(keyName, item);
334
332
  }
335
333
  });
336
334
  } else {
@@ -356,15 +354,13 @@ class FTableHttpClient {
356
354
  return; // Skip null or undefined values
357
355
  }
358
356
  if (Array.isArray(value)) {
359
- // Clean key: remove trailing [] if present
360
- const cleanKey = key.replace(/\[\]$/, '');
361
- const paramKey = cleanKey + '[]'; // Always use [] suffix once
357
+ const keyName = key.endsWith('[]') ? key : key + '[]';
362
358
 
363
359
  // Append each item in the array with the same key
364
360
  // This generates query strings like `key=val1&key=val2&key=val3`
365
361
  value.forEach(item => {
366
362
  if (item !== null && item !== undefined) { // Ensure array items are also not null/undefined
367
- formData.append(paramKey, item);
363
+ formData.append(keyName, item);
368
364
  }
369
365
  });
370
366
  } else {
@@ -1412,28 +1408,108 @@ class FTableFormBuilder {
1412
1408
  }
1413
1409
 
1414
1410
  createCustomMultiSelect(fieldName, field, value, attributes, name) {
1415
- // Create container
1411
+ const optionsSource = Array.isArray(field.options) ? field.options :
1412
+ (field.options && typeof field.options === 'object')
1413
+ ? Object.entries(field.options).map(([k, v]) => ({ Value: k, DisplayText: v }))
1414
+ : [];
1415
+
1416
+ // Support data-livesearch attribute on the virtual select as well as field.livesearch
1417
+ const livesearch = field.livesearch ?? false;
1418
+
1419
+ return this._buildCustomMultiSelect({
1420
+ containerId: fieldName,
1421
+ hiddenSelectId: `Edit-${fieldName}`,
1422
+ hiddenSelectName: name,
1423
+ extraClasses: '',
1424
+ containerDataFieldName: fieldName,
1425
+ hiddenSelectAttributes: {},
1426
+ optionsSource,
1427
+ initialValues: Array.isArray(value) ? value :
1428
+ (value ? value.toString().split(',').filter(v => v) : []),
1429
+ placeholderText: field.placeholder || this.options.messages?.multiSelectPlaceholder || 'Click to select options...',
1430
+ livesearch,
1431
+ onChangeExtra: (hiddenSelect) => {
1432
+ hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
1433
+ },
1434
+ buildHiddenSelectOnUpdate: true, // form-mode: rebuild hidden options each time
1435
+ });
1436
+ }
1437
+
1438
+ /**
1439
+ * Shared builder for both form-mode and search-mode custom multi-selects.
1440
+ *
1441
+ * config {
1442
+ * containerId string – used for data-field-name on the outer container
1443
+ * hiddenSelectId string – id attribute of the hidden <select>
1444
+ * hiddenSelectName string – name attribute of the hidden <select>
1445
+ * extraClasses string – extra CSS classes on the container (space-separated)
1446
+ * containerDataFieldName string – value for container's data-field-name
1447
+ * hiddenSelectAttributes object – extra attributes for the hidden <select>
1448
+ * optionsSource array – resolved array of option objects: { Value, DisplayText, Group? }
1449
+ * initialValues array – pre-selected values
1450
+ * placeholderText string
1451
+ * livesearch bool – show filter input inside dropdown
1452
+ * onChangeExtra fn(hiddenSelect) – called after every selection change
1453
+ * buildHiddenSelectOnUpdate bool – if true, rebuild hidden <option>s in updateDisplay (form mode);
1454
+ * if false, all options are pre-populated (search mode)
1455
+ * }
1456
+ */
1457
+ _buildCustomMultiSelect(config) {
1458
+ const {
1459
+ hiddenSelectId,
1460
+ hiddenSelectName,
1461
+ extraClasses,
1462
+ containerDataFieldName,
1463
+ hiddenSelectAttributes,
1464
+ optionsSource,
1465
+ initialValues,
1466
+ placeholderText,
1467
+ livesearch,
1468
+ onChangeExtra,
1469
+ buildHiddenSelectOnUpdate,
1470
+ } = config;
1471
+
1472
+ // Normalise options to a flat array of { optValue, optText, groupLabel }
1473
+ const normaliseOptions = (src) => {
1474
+ if (!src) return [];
1475
+ const arr = Array.isArray(src) ? src :
1476
+ Object.entries(src).map(([k, v]) => ({ Value: k, DisplayText: v }));
1477
+ return arr
1478
+ .map(o => ({
1479
+ optValue: (o.Value !== undefined ? o.Value :
1480
+ o.value !== undefined ? o.value : o),
1481
+ optText: o.DisplayText || o.text || o,
1482
+ groupLabel: o.Group || o.group || null,
1483
+ }))
1484
+ .filter(o => o.optValue != null && o.optValue !== '');
1485
+ };
1486
+
1487
+ const allOptions = normaliseOptions(optionsSource);
1488
+
1489
+ // Build a value→text lookup (used by updateDisplay)
1490
+ const optionsMap = new Map(allOptions.map(o => [o.optValue.toString(), o.optText]));
1491
+
1492
+ // ---------- DOM skeleton ----------
1493
+ const containerClasses = ['ftable-multiselect-container', ...extraClasses.split(' ').filter(Boolean)].join(' ');
1416
1494
  const container = FTableDOMHelper.create('div', {
1417
- className: 'ftable-multiselect-container',
1418
- attributes: { 'data-field-name': fieldName }
1495
+ className: containerClasses,
1496
+ attributes: { 'data-field-name': containerDataFieldName }
1419
1497
  });
1420
1498
 
1421
- // Create hidden input to store selected values
1422
- const hiddenInput = FTableDOMHelper.create('input', {
1423
- type: 'hidden',
1424
- name: name,
1425
- id: `Edit-${fieldName}`,
1426
- value: Array.isArray(value) ? value.join(',') : value || ''
1499
+ const hiddenSelect = FTableDOMHelper.create('select', {
1500
+ id: hiddenSelectId,
1501
+ name: hiddenSelectName,
1502
+ multiple: true,
1503
+ style: 'display: none;',
1504
+ attributes: hiddenSelectAttributes
1427
1505
  });
1428
- container.appendChild(hiddenInput);
1506
+ container.appendChild(hiddenSelect);
1507
+ container.hiddenSelect = hiddenSelect;
1429
1508
 
1430
- // Create display area
1431
1509
  const display = FTableDOMHelper.create('div', {
1432
1510
  className: 'ftable-multiselect-display',
1433
1511
  parent: container,
1434
- attributes: {
1435
- tabindex: '0' // Makes it focusable and in tab order
1436
- }
1512
+ attributes: { tabindex: '0' }
1437
1513
  });
1438
1514
 
1439
1515
  const selectedDisplay = FTableDOMHelper.create('div', {
@@ -1441,294 +1517,288 @@ class FTableFormBuilder {
1441
1517
  parent: display
1442
1518
  });
1443
1519
 
1444
- const placeholderText = field.placeholder || this.options.messages.multiSelectPlaceholder || 'Click to select options...';
1445
- const placeholder = FTableDOMHelper.create('span', {
1520
+ const placeholderEl = FTableDOMHelper.create('span', {
1446
1521
  className: 'ftable-multiselect-placeholder',
1447
1522
  textContent: placeholderText,
1448
1523
  parent: selectedDisplay
1449
1524
  });
1450
1525
 
1451
- // Create dropdown toggle button
1452
- const toggleBtn = FTableDOMHelper.create('button', {
1526
+ FTableDOMHelper.create('button', {
1453
1527
  type: 'button',
1454
1528
  className: 'ftable-multiselect-toggle',
1455
1529
  innerHTML: '▼',
1456
1530
  parent: display,
1457
- attributes: {
1458
- tabindex: '-1' // this skips regular focus when tabbing
1459
- }
1531
+ attributes: { tabindex: '-1' }
1460
1532
  });
1461
1533
 
1462
- // Dropdown and overlay will be created on demand and appended to body
1463
- let dropdown = null;
1534
+ // ---------- State ----------
1535
+ let dropdown = null;
1464
1536
  let dropdownOverlay = null;
1537
+ const selectedValues = new Set(initialValues.map(v => v.toString()));
1538
+ const checkboxMap = new Map(); // value → checkbox element
1465
1539
 
1466
- // Store selected values and checkbox references
1467
- const selectedValues = new Set(
1468
- Array.isArray(value) ? value :
1469
- value ? value.toString().split(',').filter(v => v) : []
1470
- );
1471
- const checkboxMap = new Map(); // Map of value -> checkbox element
1540
+ // In search mode, pre-populate the hidden select once with all options
1541
+ if (!buildHiddenSelectOnUpdate) {
1542
+ allOptions.forEach(({ optValue, optText }) => {
1543
+ FTableDOMHelper.create('option', {
1544
+ value: optValue,
1545
+ textContent: optText,
1546
+ parent: hiddenSelect
1547
+ });
1548
+ });
1549
+ }
1472
1550
 
1473
- // Function to update display
1551
+ // ---------- updateDisplay ----------
1474
1552
  const updateDisplay = () => {
1475
1553
  selectedDisplay.innerHTML = '';
1476
-
1554
+
1555
+ if (buildHiddenSelectOnUpdate) {
1556
+ // Form mode: rebuild hidden <option>s to reflect current selection
1557
+ hiddenSelect.innerHTML = '';
1558
+ selectedValues.forEach(val => {
1559
+ const text = optionsMap.get(val) ?? val;
1560
+ FTableDOMHelper.create('option', {
1561
+ value: val,
1562
+ textContent: text,
1563
+ selected: true,
1564
+ parent: hiddenSelect
1565
+ });
1566
+ });
1567
+ } else {
1568
+ // Search mode: just flip selected state on existing options
1569
+ Array.from(hiddenSelect.options).forEach(opt => {
1570
+ opt.selected = selectedValues.has(opt.value);
1571
+ });
1572
+ }
1573
+
1477
1574
  if (selectedValues.size === 0) {
1478
- placeholder.textContent = placeholderText;
1479
- selectedDisplay.appendChild(placeholder);
1575
+ placeholderEl.textContent = placeholderText;
1576
+ selectedDisplay.appendChild(placeholderEl);
1480
1577
  } else {
1481
- const selectedArray = Array.from(selectedValues);
1482
- const optionsMap = new Map();
1483
-
1484
- // Build options map
1485
- if (field.options) {
1486
- const options = Array.isArray(field.options) ? field.options :
1487
- Object.entries(field.options).map(([k, v]) => ({Value: k, DisplayText: v}));
1488
-
1489
- options.forEach(opt => {
1490
- const val = opt.Value !== undefined ? opt.Value :
1491
- opt.value !== undefined ? opt.value : opt;
1492
- const text = opt.DisplayText || opt.text || opt;
1493
- optionsMap.set(val.toString(), text);
1494
- });
1495
- }
1496
-
1497
- selectedArray.forEach(val => {
1578
+ selectedValues.forEach(val => {
1498
1579
  const tag = FTableDOMHelper.create('span', {
1499
1580
  className: 'ftable-multiselect-tag',
1500
1581
  parent: selectedDisplay
1501
1582
  });
1502
-
1503
1583
  FTableDOMHelper.create('span', {
1504
1584
  className: 'ftable-multiselect-tag-text',
1505
- textContent: optionsMap.get(val.toString()) || val,
1585
+ textContent: optionsMap.get(val) || val,
1506
1586
  parent: tag
1507
1587
  });
1508
-
1509
1588
  const removeBtn = FTableDOMHelper.create('span', {
1510
1589
  className: 'ftable-multiselect-tag-remove',
1511
1590
  innerHTML: '×',
1512
1591
  parent: tag
1513
1592
  });
1514
-
1515
1593
  removeBtn.addEventListener('click', (e) => {
1516
1594
  e.stopPropagation();
1517
1595
  selectedValues.delete(val);
1518
- // Update the checkbox state
1519
- const checkbox = checkboxMap.get(val.toString());
1520
- if (checkbox) {
1521
- checkbox.checked = false;
1522
- }
1596
+ const cb = checkboxMap.get(val);
1597
+ if (cb) cb.checked = false;
1523
1598
  updateDisplay();
1524
- hiddenInput.value = Array.from(selectedValues).join(',');
1599
+ if (onChangeExtra) onChangeExtra(hiddenSelect);
1525
1600
  });
1526
1601
  });
1527
1602
  }
1528
-
1529
- hiddenInput.value = Array.from(selectedValues).join(',');
1603
+
1604
+ if (onChangeExtra) onChangeExtra(hiddenSelect);
1530
1605
  };
1531
1606
 
1532
- // Function to close dropdown
1607
+ // ---------- Dropdown helpers ----------
1533
1608
  const closeDropdown = () => {
1534
- display.focus(); // Return focus to the trigger
1535
- if (dropdown) {
1536
- dropdown.remove();
1537
- dropdown = null;
1538
- }
1539
- if (dropdownOverlay) {
1540
- dropdownOverlay.remove();
1541
- dropdownOverlay = null;
1542
- }
1609
+ display.focus();
1610
+ if (dropdown) { dropdown.remove(); dropdown = null; }
1611
+ if (dropdownOverlay) { dropdownOverlay.remove(); dropdownOverlay = null; }
1543
1612
  if (container._cleanupHandlers) {
1544
1613
  container._cleanupHandlers();
1545
1614
  container._cleanupHandlers = null;
1546
1615
  }
1547
1616
  };
1548
1617
 
1549
- // Function to position dropdown
1550
1618
  const positionDropdown = () => {
1551
1619
  if (!dropdown) return;
1552
-
1553
- const rect = display.getBoundingClientRect();
1554
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1555
- const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
1556
-
1620
+ const rect = display.getBoundingClientRect();
1621
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1622
+ const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
1557
1623
  let left = rect.left + scrollLeft;
1558
- let top = rect.bottom + scrollTop + 4; // 4px gap
1559
-
1560
- dropdown.style.position = 'absolute';
1561
- dropdown.style.left = `${left}px`;
1562
- dropdown.style.top = `${top}px`;
1563
- dropdown.style.width = `${rect.width}px`;
1564
- dropdown.style.minWidth = `${rect.width}px`;
1565
- dropdown.style.boxSizing = 'border-box';
1566
- dropdown.style.zIndex = '10000';
1567
-
1568
- // Adjust horizontal position if needed
1569
- const dropdownRect = dropdown.getBoundingClientRect();
1570
- const viewportWidth = window.innerWidth;
1571
- if (dropdownRect.right > viewportWidth) {
1572
- left = Math.max(10, viewportWidth - dropdownRect.width - 10);
1624
+ let top = rect.bottom + scrollTop + 4;
1625
+
1626
+ Object.assign(dropdown.style, {
1627
+ position: 'absolute',
1628
+ left: `${left}px`,
1629
+ top: `${top}px`,
1630
+ width: `${rect.width}px`,
1631
+ minWidth: 'fit-content',
1632
+ boxSizing: 'border-box',
1633
+ zIndex: '10000',
1634
+ });
1635
+
1636
+ const ddRect = dropdown.getBoundingClientRect();
1637
+ if (ddRect.right > window.innerWidth) {
1638
+ left = Math.max(10, window.innerWidth - ddRect.width - 10);
1573
1639
  dropdown.style.left = `${left}px`;
1574
1640
  }
1575
1641
  };
1576
1642
 
1577
- // Populate options
1578
- const populateOptions = () => {
1579
- if (!field.options || !dropdown) return;
1580
-
1581
- const options = Array.isArray(field.options) ? field.options :
1582
- Object.entries(field.options).map(([k, v]) => ({Value: k, DisplayText: v}));
1583
-
1584
- options.forEach(option => {
1585
- const optValue = option.Value !== undefined ? option.Value :
1586
- option.value !== undefined ? option.value : option;
1587
-
1588
- // Skip if value is empty
1589
- if (optValue == null || optValue === '') {
1590
- return; // This continues to the next iteration
1643
+ // Render options (or a filtered subset) into the open dropdown
1644
+ const renderDropdownOptions = (filterText = '') => {
1645
+ if (!dropdown) return;
1646
+ // Remove existing option rows (but keep the search bar if present)
1647
+ Array.from(dropdown.querySelectorAll('.ftable-multiselect-option, .ftable-multiselect-optgroup'))
1648
+ .forEach(el => el.remove());
1649
+
1650
+ const lc = filterText.toLowerCase();
1651
+ const visible = filterText
1652
+ ? allOptions.filter(o => o.optText.toLowerCase().includes(lc))
1653
+ : allOptions;
1654
+
1655
+ // Group rendering
1656
+ const usedGroups = new Set();
1657
+ let currentOptgroup = null;
1658
+
1659
+ visible.forEach(({ optValue, optText, groupLabel }) => {
1660
+ if (groupLabel && groupLabel !== usedGroups[usedGroups.size - 1]) {
1661
+ if (!usedGroups.has(groupLabel)) {
1662
+ usedGroups.add(groupLabel);
1663
+ currentOptgroup = FTableDOMHelper.create('div', {
1664
+ className: 'ftable-multiselect-optgroup',
1665
+ textContent: groupLabel,
1666
+ parent: dropdown
1667
+ });
1668
+ }
1669
+ } else if (!groupLabel) {
1670
+ currentOptgroup = null;
1591
1671
  }
1592
1672
 
1593
- const optText = option.DisplayText || option.text || option;
1594
-
1595
1673
  const optionDiv = FTableDOMHelper.create('div', {
1596
1674
  className: 'ftable-multiselect-option',
1597
1675
  parent: dropdown
1598
1676
  });
1599
-
1677
+
1600
1678
  const checkbox = FTableDOMHelper.create('input', {
1601
1679
  type: 'checkbox',
1602
1680
  className: 'ftable-multiselect-checkbox',
1603
1681
  checked: selectedValues.has(optValue.toString()),
1604
1682
  parent: optionDiv
1605
1683
  });
1606
-
1607
- // Store checkbox reference
1684
+
1608
1685
  checkboxMap.set(optValue.toString(), checkbox);
1609
-
1610
- const label = FTableDOMHelper.create('label', {
1686
+
1687
+ FTableDOMHelper.create('label', {
1611
1688
  className: 'ftable-multiselect-label',
1612
1689
  textContent: optText,
1613
1690
  parent: optionDiv
1614
1691
  });
1615
-
1616
- // Click anywhere on the option to toggle
1692
+
1617
1693
  optionDiv.addEventListener('click', (e) => {
1618
1694
  e.stopPropagation();
1619
-
1620
- if (selectedValues.has(optValue.toString())) {
1621
- selectedValues.delete(optValue.toString());
1695
+ const key = optValue.toString();
1696
+ if (selectedValues.has(key)) {
1697
+ selectedValues.delete(key);
1622
1698
  checkbox.checked = false;
1623
1699
  } else {
1624
- selectedValues.add(optValue.toString());
1700
+ selectedValues.add(key);
1625
1701
  checkbox.checked = true;
1626
1702
  }
1627
-
1628
1703
  updateDisplay();
1629
1704
  });
1630
1705
  });
1631
1706
  };
1632
1707
 
1633
- // Toggle dropdown
1708
+ // ---------- toggleDropdown ----------
1634
1709
  const toggleDropdown = (e) => {
1635
1710
  if (e) e.stopPropagation();
1636
-
1711
+
1637
1712
  if (dropdown) {
1638
- // Dropdown is open, close it
1639
1713
  closeDropdown();
1640
- } else {
1641
- // Close any other open multiselect dropdowns
1642
- document.querySelectorAll('.ftable-multiselect-dropdown').forEach(dd => dd.remove());
1643
- document.querySelectorAll('.ftable-multiselect-overlay').forEach(ov => ov.remove());
1644
-
1645
- // Create overlay
1646
- dropdownOverlay = FTableDOMHelper.create('div', {
1647
- className: 'ftable-multiselect-overlay',
1648
- parent: document.body
1649
- });
1714
+ return;
1715
+ }
1650
1716
 
1651
- // Create dropdown
1652
- dropdown = FTableDOMHelper.create('div', {
1653
- className: 'ftable-multiselect-dropdown',
1654
- parent: document.body,
1655
- attributes: {
1656
- tabindex: '-1',
1657
- role: 'listbox',
1658
- 'aria-multiselectable': 'true'
1659
- }
1660
- });
1717
+ // Close any other open dropdowns
1718
+ document.querySelectorAll('.ftable-multiselect-dropdown').forEach(dd => dd.remove());
1719
+ document.querySelectorAll('.ftable-multiselect-overlay').forEach(ov => ov.remove());
1661
1720
 
1662
- // Populate options
1663
- populateOptions();
1721
+ dropdownOverlay = FTableDOMHelper.create('div', {
1722
+ className: 'ftable-multiselect-overlay',
1723
+ parent: document.body
1724
+ });
1664
1725
 
1665
- // Position dropdown
1666
- positionDropdown();
1726
+ dropdown = FTableDOMHelper.create('div', {
1727
+ className: 'ftable-multiselect-dropdown',
1728
+ parent: document.body,
1729
+ attributes: { tabindex: '-1', role: 'listbox', 'aria-multiselectable': 'true' }
1730
+ });
1667
1731
 
1668
- // dropdown focus
1669
- dropdown.focus();
1732
+ // Optional live-search bar
1733
+ if (livesearch) {
1734
+ const searchWrap = FTableDOMHelper.create('div', {
1735
+ className: 'ftable-multiselect-livesearch-wrap',
1736
+ parent: dropdown
1737
+ });
1738
+ const searchInput = FTableDOMHelper.create('input', {
1739
+ type: 'search',
1740
+ className: 'ftable-multiselect-livesearch',
1741
+ placeholder: 'Search...',
1742
+ parent: searchWrap,
1743
+ attributes: { autocomplete: 'off' }
1744
+ });
1745
+ searchInput.addEventListener('input', () => {
1746
+ renderDropdownOptions(searchInput.value);
1747
+ });
1748
+ searchInput.addEventListener('click', e => e.stopPropagation());
1749
+ // Focus search input automatically
1750
+ setTimeout(() => searchInput.focus(), 0);
1751
+ }
1670
1752
 
1671
- // Add keyboard navigation
1672
- dropdown.addEventListener('keydown', (e) => {
1673
- if (e.key === 'Escape') {
1674
- closeDropdown();
1675
- } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1676
- e.preventDefault();
1677
- // Navigate between options
1678
- const checkboxes = Array.from(dropdown.querySelectorAll('.ftable-multiselect-checkbox'));
1679
- const current = document.activeElement;
1680
- const currentIndex = checkboxes.indexOf(current);
1681
-
1682
- let nextIndex;
1683
- if (e.key === 'ArrowDown') {
1684
- nextIndex = currentIndex < checkboxes.length - 1 ? currentIndex + 1 : 0;
1685
- } else {
1686
- nextIndex = currentIndex > 0 ? currentIndex - 1 : checkboxes.length - 1;
1687
- }
1753
+ renderDropdownOptions();
1754
+ positionDropdown();
1688
1755
 
1689
- checkboxes[nextIndex].focus();
1690
- } else if (e.key === ' ' || e.key === 'Enter') {
1691
- e.preventDefault();
1692
- // Toggle the focused checkbox
1693
- if (document.activeElement.classList.contains('ftable-multiselect-checkbox')) {
1694
- document.activeElement.click();
1695
- }
1696
- }
1697
- });
1756
+ if (!livesearch) dropdown.focus();
1698
1757
 
1699
- // Handle clicks outside
1700
- dropdownOverlay.addEventListener('click', (event) => {
1701
- if (event.target === dropdownOverlay) {
1702
- closeDropdown();
1758
+ // Keyboard navigation
1759
+ dropdown.addEventListener('keydown', (e) => {
1760
+ if (e.key === 'Escape') {
1761
+ closeDropdown();
1762
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1763
+ e.preventDefault();
1764
+ const checkboxes = Array.from(dropdown.querySelectorAll('.ftable-multiselect-checkbox'));
1765
+ const currentIndex = checkboxes.indexOf(document.activeElement);
1766
+ const nextIndex = e.key === 'ArrowDown'
1767
+ ? (currentIndex < checkboxes.length - 1 ? currentIndex + 1 : 0)
1768
+ : (currentIndex > 0 ? currentIndex - 1 : checkboxes.length - 1);
1769
+ checkboxes[nextIndex]?.focus();
1770
+ } else if (e.key === ' ' || e.key === 'Enter') {
1771
+ e.preventDefault();
1772
+ if (document.activeElement.classList.contains('ftable-multiselect-checkbox')) {
1773
+ document.activeElement.click();
1703
1774
  }
1704
- });
1775
+ }
1776
+ });
1705
1777
 
1706
- // Reposition on scroll/resize
1707
- const repositionHandler = () => positionDropdown();
1708
- const scrollHandler = (e) => {
1709
- if (dropdown && dropdown.contains(e.target)) {
1710
- return; // Allow scrolling inside dropdown
1711
- }
1712
- positionDropdown();
1713
- };
1714
- const selectedResizeObserver = new ResizeObserver(() => {
1715
- positionDropdown();
1716
- });
1717
- window.addEventListener('scroll', scrollHandler, true);
1718
- window.addEventListener('resize', repositionHandler);
1719
- selectedResizeObserver.observe(selectedDisplay);
1720
-
1721
- // Store cleanup function
1722
- container._cleanupHandlers = () => {
1723
- window.removeEventListener('scroll', scrollHandler, true);
1724
- window.removeEventListener('resize', repositionHandler);
1725
- selectedResizeObserver.disconnect();
1726
- };
1727
- }
1778
+ dropdownOverlay.addEventListener('click', (event) => {
1779
+ if (event.target === dropdownOverlay) closeDropdown();
1780
+ });
1781
+
1782
+ // Reposition on scroll / resize
1783
+ const repositionHandler = () => positionDropdown();
1784
+ const scrollHandler = (e) => {
1785
+ if (dropdown && dropdown.contains(e.target)) return;
1786
+ positionDropdown();
1787
+ };
1788
+ const resizeObserver = new ResizeObserver(() => positionDropdown());
1789
+ window.addEventListener('scroll', scrollHandler, true);
1790
+ window.addEventListener('resize', repositionHandler);
1791
+ resizeObserver.observe(selectedDisplay);
1792
+
1793
+ container._cleanupHandlers = () => {
1794
+ window.removeEventListener('scroll', scrollHandler, true);
1795
+ window.removeEventListener('resize', repositionHandler);
1796
+ resizeObserver.disconnect();
1797
+ };
1728
1798
  };
1729
1799
 
1730
1800
  display.addEventListener('click', toggleDropdown);
1731
- toggleBtn.addEventListener('click', toggleDropdown);
1801
+ display.querySelector('.ftable-multiselect-toggle').addEventListener('click', toggleDropdown);
1732
1802
  display.addEventListener('keydown', (e) => {
1733
1803
  if (e.key === 'ArrowDown' || e.key === 'Enter') {
1734
1804
  e.preventDefault();
@@ -1736,29 +1806,32 @@ class FTableFormBuilder {
1736
1806
  }
1737
1807
  });
1738
1808
 
1739
- // Clean up when container is removed from DOM
1740
- const observer = new MutationObserver((mutations) => {
1809
+ // Reset method (used by search toolbar)
1810
+ container.resetMultiSelect = () => {
1811
+ selectedValues.clear();
1812
+ checkboxMap.forEach(cb => { cb.checked = false; });
1813
+ closeDropdown();
1814
+ updateDisplay();
1815
+ };
1816
+
1817
+ // Cleanup when container is removed from DOM
1818
+ const domObserver = new MutationObserver((mutations) => {
1741
1819
  mutations.forEach((mutation) => {
1742
1820
  mutation.removedNodes.forEach((node) => {
1743
- if (node === container || node.contains && node.contains(container)) {
1821
+ if (node === container || (node.contains && node.contains(container))) {
1744
1822
  closeDropdown();
1745
- observer.disconnect();
1823
+ domObserver.disconnect();
1746
1824
  }
1747
1825
  });
1748
1826
  });
1749
1827
  });
1750
-
1751
- // Start observing once container is in the DOM
1752
1828
  setTimeout(() => {
1753
1829
  if (container.parentNode) {
1754
- observer.observe(container.parentNode, { childList: true, subtree: true });
1830
+ domObserver.observe(container.parentNode, { childList: true, subtree: true });
1755
1831
  }
1756
1832
  }, 0);
1757
1833
 
1758
- // Initialize
1759
- populateOptions();
1760
1834
  updateDisplay();
1761
-
1762
1835
  return container;
1763
1836
  }
1764
1837
 
@@ -1868,7 +1941,21 @@ class FTableFormBuilder {
1868
1941
  select.innerHTML = ''; // Clear existing options
1869
1942
 
1870
1943
  if (Array.isArray(options)) {
1944
+ // Group options by their Group property (if any)
1945
+ const groups = new Map(); // groupLabel -> [options]
1946
+ const ungrouped = [];
1947
+
1871
1948
  options.forEach(option => {
1949
+ const groupLabel = option.Group || option.group || null;
1950
+ if (groupLabel) {
1951
+ if (!groups.has(groupLabel)) groups.set(groupLabel, []);
1952
+ groups.get(groupLabel).push(option);
1953
+ } else {
1954
+ ungrouped.push(option);
1955
+ }
1956
+ });
1957
+
1958
+ const appendOption = (option, parent) => {
1872
1959
  const value = option.Value !== undefined ? option.Value :
1873
1960
  option.value !== undefined ? option.value :
1874
1961
  option; // fallback for string
@@ -1876,19 +1963,30 @@ class FTableFormBuilder {
1876
1963
  value: value,
1877
1964
  textContent: option.DisplayText || option.text || option,
1878
1965
  selected: value == selectedValue,
1879
- parent: select
1966
+ parent: parent
1880
1967
  });
1881
-
1882
1968
  if (option.Data && typeof option.Data === 'object') {
1883
1969
  Object.entries(option.Data).forEach(([key, dataValue]) => {
1884
1970
  optionElement.setAttribute(`data-${key}`, dataValue);
1885
1971
  });
1886
1972
  }
1973
+ };
1974
+
1975
+ // Render ungrouped options first
1976
+ ungrouped.forEach(option => appendOption(option, select));
1887
1977
 
1978
+ // Render grouped options inside <optgroup> elements
1979
+ groups.forEach((groupOptions, label) => {
1980
+ const optgroup = FTableDOMHelper.create('optgroup', {
1981
+ attributes: { label },
1982
+ parent: select
1983
+ });
1984
+ groupOptions.forEach(option => appendOption(option, optgroup));
1888
1985
  });
1986
+
1889
1987
  } else if (typeof options === 'object') {
1890
1988
  Object.entries(options).forEach(([key, text]) => {
1891
- const optionElement = FTableDOMHelper.create('option', {
1989
+ FTableDOMHelper.create('option', {
1892
1990
  value: key,
1893
1991
  textContent: text,
1894
1992
  selected: key == selectedValue,
@@ -2088,7 +2186,7 @@ class FTable extends FTableEventEmitter {
2088
2186
  initColumnWidths() {
2089
2187
  const visibleFields = this.columnList.filter(fieldName => {
2090
2188
  const field = this.options.fields[fieldName];
2091
- return field.visibility !== 'hidden' && field.visibility !== 'separator';
2189
+ return !field.action && field.visibility !== 'hidden' && field.visibility !== 'separator';
2092
2190
  });
2093
2191
 
2094
2192
  const count = visibleFields.length;
@@ -2107,7 +2205,7 @@ class FTable extends FTableEventEmitter {
2107
2205
  th: this.elements.table.querySelector(`[data-field-name="${fieldName}"]`),
2108
2206
  field: this.options.fields[fieldName]
2109
2207
  }))
2110
- .filter(item => item.th && item.field.visibility !== 'hidden' && item.field.visibility !== 'separator');
2208
+ .filter(item => item.th && !item.field.action && item.field.visibility !== 'hidden' && item.field.visibility !== 'separator');
2111
2209
 
2112
2210
  if (visibleHeaders.length === 0) return;
2113
2211
 
@@ -2242,8 +2340,17 @@ class FTable extends FTableEventEmitter {
2242
2340
  this.fieldList.forEach(fieldName => {
2243
2341
  const field = this.options.fields[fieldName];
2244
2342
  const isKeyField = field.key === true;
2245
-
2246
- if (isKeyField) {
2343
+ const isActionField = !!field.action; // action: 'select' | 'update' | 'clone' | 'delete'
2344
+
2345
+ if (isActionField) {
2346
+ // Action columns are always listed but never part of forms or sorting
2347
+ field.list = true;
2348
+ field.create = false;
2349
+ field.edit = false;
2350
+ field.sorting = false;
2351
+ field.searchable = false;
2352
+ field.visibility = field.visibility ?? 'visible';
2353
+ } else if (isKeyField) {
2247
2354
  if (field.create === undefined || !field.create) {
2248
2355
  field.create = true;
2249
2356
  field.type = 'hidden';
@@ -2268,6 +2375,13 @@ class FTable extends FTableEventEmitter {
2268
2375
  return field.list !== false;
2269
2376
  });
2270
2377
 
2378
+ // Track which actions are user-placed (via action columns in fields)
2379
+ this._userPlacedActions = new Set(
2380
+ this.fieldList
2381
+ .filter(name => this.options.fields[name].action)
2382
+ .map(name => this.options.fields[name].action)
2383
+ );
2384
+
2271
2385
  // Find key field
2272
2386
  this.keyField = this.fieldList.find(name => this.options.fields[name].key === true);
2273
2387
  if (!this.keyField) {
@@ -2278,6 +2392,7 @@ class FTable extends FTableEventEmitter {
2278
2392
  async resolveAsyncFieldOptions() {
2279
2393
  const promises = this.columnList.map(async (fieldName) => {
2280
2394
  const field = this.options.fields[fieldName];
2395
+ if (field.action) return; // Skip action columns
2281
2396
  const originalOptions = this.formBuilder.originalFieldOptions.get(fieldName);
2282
2397
 
2283
2398
  if (this.formBuilder.shouldResolveOptions(originalOptions)) {
@@ -2303,7 +2418,7 @@ class FTable extends FTableEventEmitter {
2303
2418
  for (const row of rows) {
2304
2419
  for (const fieldName of this.columnList) {
2305
2420
  const field = this.options.fields[fieldName];
2306
- if (!field.options) continue;
2421
+ if (field.action || !field.options) continue;
2307
2422
 
2308
2423
  const cell = row.querySelector(`td[data-field-name="${fieldName}"]`);
2309
2424
  if (!cell) continue;
@@ -2374,8 +2489,8 @@ class FTable extends FTableEventEmitter {
2374
2489
  parent: thead
2375
2490
  });
2376
2491
 
2377
- // Add selecting column if enabled
2378
- if (this.options.selecting && this.options.selectingCheckboxes) {
2492
+ // Add selecting column if enabled (only if not user-placed)
2493
+ if (this.options.selecting && this.options.selectingCheckboxes && !this._userPlacedActions.has('select')) {
2379
2494
  const selectHeader = FTableDOMHelper.create('th', {
2380
2495
  className: `ftable-command-column-header ftable-column-header-select`,
2381
2496
  parent: headerRow
@@ -2393,9 +2508,41 @@ class FTable extends FTableEventEmitter {
2393
2508
  }
2394
2509
  }
2395
2510
 
2396
- // Add data columns
2511
+ // Add data columns (including any user-placed action columns)
2397
2512
  this.columnList.forEach(fieldName => {
2398
2513
  const field = this.options.fields[fieldName];
2514
+
2515
+ // If this column is a user-placed action column, render an action header
2516
+ if (field.action) {
2517
+ const actionClassMap = {
2518
+ select: 'ftable-column-header-select',
2519
+ update: 'ftable-column-header-edit',
2520
+ clone: 'ftable-column-header-clone',
2521
+ delete: 'ftable-column-header-delete',
2522
+ };
2523
+ const th = FTableDOMHelper.create('th', {
2524
+ className: `ftable-command-column-header ${actionClassMap[field.action] || ''}`,
2525
+ parent: headerRow
2526
+ });
2527
+ if (field.title) {
2528
+ th.textContent = field.title;
2529
+ }
2530
+ // For select action with multiselect, add the select-all checkbox
2531
+ if (field.action === 'select' && this.options.selecting && this.options.selectingCheckboxes && this.options.multiselect) {
2532
+ const selectAllCheckbox = FTableDOMHelper.create('input', {
2533
+ attributes: { type: 'checkbox' },
2534
+ parent: th
2535
+ });
2536
+ selectAllCheckbox.addEventListener('change', () => {
2537
+ this.toggleSelectAll(selectAllCheckbox.checked);
2538
+ });
2539
+ }
2540
+ if (field.width) {
2541
+ th.style.width = field.width;
2542
+ }
2543
+ return;
2544
+ }
2545
+
2399
2546
  const th = FTableDOMHelper.create('th', {
2400
2547
  className: `ftable-column-header ${field.listClass || ''} ${field.listClassHeader || ''}`,
2401
2548
  attributes: { 'data-field-name': fieldName },
@@ -2444,22 +2591,22 @@ class FTable extends FTableEventEmitter {
2444
2591
  }
2445
2592
  });
2446
2593
 
2447
- // Add action columns
2448
- if (this.options.actions.updateAction) {
2594
+ // Add default action columns only if not user-placed
2595
+ if (this.options.actions.updateAction && !this._userPlacedActions.has('update')) {
2449
2596
  FTableDOMHelper.create('th', {
2450
2597
  className: 'ftable-command-column-header ftable-column-header-edit',
2451
2598
  parent: headerRow
2452
2599
  });
2453
2600
  }
2454
2601
 
2455
- if (this.options.actions.cloneAction) {
2602
+ if (this.options.actions.cloneAction && !this._userPlacedActions.has('clone')) {
2456
2603
  FTableDOMHelper.create('th', {
2457
2604
  className: 'ftable-command-column-header ftable-column-header-clone',
2458
2605
  parent: headerRow
2459
2606
  });
2460
2607
  }
2461
2608
 
2462
- if (this.options.actions.deleteAction) {
2609
+ if (this.options.actions.deleteAction && !this._userPlacedActions.has('delete')) {
2463
2610
  FTableDOMHelper.create('th', {
2464
2611
  className: 'ftable-command-column-header ftable-column-header-delete',
2465
2612
  parent: headerRow
@@ -2480,17 +2627,27 @@ class FTable extends FTableEventEmitter {
2480
2627
  parent: theadParent
2481
2628
  });
2482
2629
 
2483
- // Add empty cell for selecting column if enabled
2484
- if (this.options.selecting && this.options.selectingCheckboxes) {
2630
+ // Add empty cell for selecting column if enabled (only if not user-placed)
2631
+ if (this.options.selecting && this.options.selectingCheckboxes && !this._userPlacedActions.has('select')) {
2485
2632
  FTableDOMHelper.create('th', {
2486
2633
  className: 'ftable-toolbarsearch-column-header',
2487
2634
  parent: searchRow
2488
2635
  });
2489
2636
  }
2490
2637
 
2491
- // Add search input cells for data columns
2638
+ // Add search input cells for data columns (including user-placed action columns)
2492
2639
  for (const fieldName of this.columnList) {
2493
2640
  const field = this.options.fields[fieldName];
2641
+
2642
+ // Action columns get an empty search cell
2643
+ if (field.action) {
2644
+ FTableDOMHelper.create('th', {
2645
+ className: 'ftable-toolbarsearch-column-header ftable-command-column-header',
2646
+ parent: searchRow
2647
+ });
2648
+ continue;
2649
+ }
2650
+
2494
2651
  const isSearchable = field.searchable !== false;
2495
2652
 
2496
2653
  const th = FTableDOMHelper.create('th', {
@@ -2662,9 +2819,9 @@ class FTable extends FTableEventEmitter {
2662
2819
  parent: searchRow
2663
2820
  });
2664
2821
 
2665
- const actionCount = (this.options.actions.updateAction ? 1 : 0) +
2666
- (this.options.actions.deleteAction ? 1 : 0) +
2667
- (this.options.actions.cloneAction ? 1 : 0);
2822
+ const actionCount = (this.options.actions.updateAction && !this._userPlacedActions.has('update') ? 1 : 0) +
2823
+ (this.options.actions.deleteAction && !this._userPlacedActions.has('delete') ? 1 : 0) +
2824
+ (this.options.actions.cloneAction && !this._userPlacedActions.has('clone') ? 1 : 0);
2668
2825
 
2669
2826
  if (actionCount > 0) {
2670
2827
  resetTh.colSpan = actionCount;
@@ -2772,379 +2929,26 @@ class FTable extends FTableEventEmitter {
2772
2929
  }
2773
2930
 
2774
2931
  createCustomMultiSelectForSearch(fieldSearchName, fieldName, field, optionsSource, attributes) {
2775
- // Create container
2776
- const container = FTableDOMHelper.create('div', {
2777
- className: 'ftable-multiselect-container ftable-multiselect-search ftable-toolbarsearch',
2778
- attributes: { 'data-field-name': attributes['data-field-name'] }
2779
- });
2780
-
2781
- // Create hidden select to maintain compatibility with existing search logic
2782
- const hiddenSelect = FTableDOMHelper.create('select', {
2783
- id: fieldSearchName,
2784
- multiple: true,
2785
- style: 'display: none;',
2786
- attributes: attributes
2787
- });
2788
- container.appendChild(hiddenSelect);
2789
-
2790
- // Expose hidden select for external access (needed for event listeners and reset)
2791
- container.hiddenSelect = hiddenSelect;
2792
-
2793
- // Create display area
2794
- const display = FTableDOMHelper.create('div', {
2795
- className: 'ftable-multiselect-display',
2796
- parent: container,
2797
- attributes: {
2798
- tabindex: '0' // Makes it focusable and in tab order
2799
- }
2800
- });
2801
-
2802
- const selectedDisplay = FTableDOMHelper.create('div', {
2803
- className: 'ftable-multiselect-selected',
2804
- parent: display
2805
- });
2806
-
2807
- const placeholderText = field.searchPlaceholder || field.placeholder || this.options.messages.multiSelectPlaceholder || 'Click to select options...';
2808
- const placeholder = FTableDOMHelper.create('span', {
2809
- className: 'ftable-multiselect-placeholder',
2810
- textContent: placeholderText,
2811
- parent: selectedDisplay
2812
- });
2813
-
2814
- // Create dropdown toggle button
2815
- const toggleBtn = FTableDOMHelper.create('button', {
2816
- type: 'button',
2817
- className: 'ftable-multiselect-toggle',
2818
- innerHTML: '▼',
2819
- parent: display,
2820
- attributes: {
2821
- tabindex: '-1' // this skips regular focus when tabbing
2822
- }
2823
- });
2824
-
2825
- // Dropdown and overlay will be created on demand and appended to body
2826
- let dropdown = null;
2827
- let dropdownOverlay = null;
2828
-
2829
- // Store selected values and checkbox references
2830
- const selectedValues = new Set();
2831
- const checkboxMap = new Map(); // Map of value -> checkbox element
2832
-
2833
- // Function to update display and hidden select
2834
- const updateDisplay = () => {
2835
- selectedDisplay.innerHTML = '';
2836
-
2837
- // Update hidden select
2838
- Array.from(hiddenSelect.options).forEach(opt => {
2839
- opt.selected = selectedValues.has(opt.value);
2840
- });
2841
-
2842
- // Trigger change event on hidden select for search functionality
2843
- hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
2844
-
2845
- if (selectedValues.size === 0) {
2846
- placeholder.textContent = placeholderText;
2847
- selectedDisplay.appendChild(placeholder);
2848
- } else {
2849
- const selectedArray = Array.from(selectedValues);
2850
- const optionsMap = new Map();
2851
-
2852
- // Build options map
2853
- if (optionsSource && Array.isArray(optionsSource)) {
2854
- optionsSource.forEach(opt => {
2855
- const val = opt.Value !== undefined ? opt.Value :
2856
- opt.value !== undefined ? opt.value : opt;
2857
- const text = opt.DisplayText || opt.text || opt;
2858
- optionsMap.set(val.toString(), text);
2859
- });
2860
- }
2861
-
2862
- selectedArray.forEach(val => {
2863
- const tag = FTableDOMHelper.create('span', {
2864
- className: 'ftable-multiselect-tag',
2865
- parent: selectedDisplay
2866
- });
2867
-
2868
- FTableDOMHelper.create('span', {
2869
- className: 'ftable-multiselect-tag-text',
2870
- textContent: optionsMap.get(val.toString()) || val,
2871
- parent: tag
2872
- });
2873
-
2874
- const removeBtn = FTableDOMHelper.create('span', {
2875
- className: 'ftable-multiselect-tag-remove',
2876
- innerHTML: '×',
2877
- parent: tag
2878
- });
2879
-
2880
- removeBtn.addEventListener('click', (e) => {
2881
- e.stopPropagation();
2882
- selectedValues.delete(val);
2883
- // Update the checkbox state
2884
- const checkbox = checkboxMap.get(val.toString());
2885
- if (checkbox) {
2886
- checkbox.checked = false;
2887
- }
2888
- updateDisplay();
2889
- });
2890
- });
2891
- }
2892
- };
2893
-
2894
- // Function to close dropdown
2895
- const closeDropdown = () => {
2896
- display.focus(); // Return focus to the trigger
2897
- if (dropdown) {
2898
- dropdown.remove();
2899
- dropdown = null;
2900
- }
2901
- if (dropdownOverlay) {
2902
- dropdownOverlay.remove();
2903
- dropdownOverlay = null;
2904
- }
2905
- if (container._cleanupHandlers) {
2906
- container._cleanupHandlers();
2907
- container._cleanupHandlers = null;
2908
- }
2909
- };
2910
-
2911
- // Function to position dropdown
2912
- const positionDropdown = () => {
2913
- if (!dropdown) return;
2914
-
2915
- const rect = display.getBoundingClientRect();
2916
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
2917
- const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
2918
-
2919
- let left = rect.left + scrollLeft;
2920
- let top = rect.bottom + scrollTop + 4; // 4px gap
2921
-
2922
- dropdown.style.position = 'absolute';
2923
- dropdown.style.left = `${left}px`;
2924
- dropdown.style.top = `${top}px`;
2925
- dropdown.style.width = `${rect.width}px`;
2926
- dropdown.style.minWidth = `${rect.width}px`;
2927
- dropdown.style.boxSizing = 'border-box';
2928
- dropdown.style.zIndex = '10000';
2929
-
2930
- // Adjust horizontal position if needed
2931
- const dropdownRect = dropdown.getBoundingClientRect();
2932
- const viewportWidth = window.innerWidth;
2933
- if (dropdownRect.right > viewportWidth) {
2934
- left = Math.max(10, viewportWidth - dropdownRect.width - 10);
2935
- dropdown.style.left = `${left}px`;
2936
- }
2937
- };
2938
-
2939
- // Populate options in both hidden select and dropdown
2940
- const populateOptions = () => {
2941
- if (!optionsSource || !dropdown) return;
2942
-
2943
- const options = Array.isArray(optionsSource) ? optionsSource :
2944
- Object.entries(optionsSource).map(([k, v]) => ({Value: k, DisplayText: v}));
2945
-
2946
- options.forEach(option => {
2947
- const optValue = option.Value !== undefined ? option.Value :
2948
- option.value !== undefined ? option.value : option;
2949
-
2950
- // Skip if value is empty
2951
- if (optValue == null || optValue === '') {
2952
- return; // This continues to the next iteration
2953
- }
2954
-
2955
- const optText = option.DisplayText || option.text || option;
2956
-
2957
- // Add to hidden select (only once)
2958
- if (!hiddenSelect.querySelector(`option[value="${optValue}"]`)) {
2959
- FTableDOMHelper.create('option', {
2960
- value: optValue,
2961
- textContent: optText,
2962
- parent: hiddenSelect
2963
- });
2964
- }
2965
-
2966
- // Add to visual dropdown
2967
- const optionDiv = FTableDOMHelper.create('div', {
2968
- className: 'ftable-multiselect-option',
2969
- parent: dropdown
2970
- });
2971
-
2972
- const checkbox = FTableDOMHelper.create('input', {
2973
- type: 'checkbox',
2974
- className: 'ftable-multiselect-checkbox',
2975
- parent: optionDiv
2976
- });
2977
-
2978
- // Set initial checked state
2979
- checkbox.checked = selectedValues.has(optValue.toString());
2980
-
2981
- // Store checkbox reference
2982
- checkboxMap.set(optValue.toString(), checkbox);
2983
-
2984
- const label = FTableDOMHelper.create('label', {
2985
- className: 'ftable-multiselect-label',
2986
- textContent: optText,
2987
- parent: optionDiv
2988
- });
2989
-
2990
- // Click anywhere on the option to toggle
2991
- optionDiv.addEventListener('click', (e) => {
2992
- e.stopPropagation();
2993
-
2994
- if (selectedValues.has(optValue.toString())) {
2995
- selectedValues.delete(optValue.toString());
2996
- checkbox.checked = false;
2997
- } else {
2998
- selectedValues.add(optValue.toString());
2999
- checkbox.checked = true;
3000
- }
3001
-
3002
- updateDisplay();
3003
- });
3004
- });
3005
- };
3006
-
3007
- // Toggle dropdown
3008
- const toggleDropdown = (e) => {
3009
- if (e) e.stopPropagation();
3010
-
3011
- if (dropdown) {
3012
- // Dropdown is open, close it
3013
- closeDropdown();
3014
- } else {
3015
- // Close any other open multiselect dropdowns
3016
- document.querySelectorAll('.ftable-multiselect-dropdown').forEach(dd => dd.remove());
3017
- document.querySelectorAll('.ftable-multiselect-overlay').forEach(ov => ov.remove());
3018
-
3019
- // Create overlay
3020
- dropdownOverlay = FTableDOMHelper.create('div', {
3021
- className: 'ftable-multiselect-overlay',
3022
- parent: document.body
3023
- });
3024
-
3025
- // Create dropdown
3026
- dropdown = FTableDOMHelper.create('div', {
3027
- className: 'ftable-multiselect-dropdown',
3028
- parent: document.body,
3029
- attributes: {
3030
- tabindex: '-1',
3031
- role: 'listbox',
3032
- 'aria-multiselectable': 'true'
3033
- }
3034
- });
3035
-
3036
- // Populate options
3037
- populateOptions();
3038
-
3039
- // Position dropdown
3040
- positionDropdown();
3041
-
3042
- // dropdown focus
3043
- dropdown.focus();
3044
-
3045
- // Add keyboard navigation
3046
- dropdown.addEventListener('keydown', (e) => {
3047
- if (e.key === 'Escape') {
3048
- closeDropdown();
3049
- } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
3050
- e.preventDefault();
3051
- // Navigate between options
3052
- const checkboxes = Array.from(dropdown.querySelectorAll('.ftable-multiselect-checkbox'));
3053
- const current = document.activeElement;
3054
- const currentIndex = checkboxes.indexOf(current);
3055
-
3056
- let nextIndex;
3057
- if (e.key === 'ArrowDown') {
3058
- nextIndex = currentIndex < checkboxes.length - 1 ? currentIndex + 1 : 0;
3059
- } else {
3060
- nextIndex = currentIndex > 0 ? currentIndex - 1 : checkboxes.length - 1;
3061
- }
3062
-
3063
- checkboxes[nextIndex].focus();
3064
- } else if (e.key === ' ' || e.key === 'Enter') {
3065
- e.preventDefault();
3066
- // Toggle the focused checkbox
3067
- if (document.activeElement.classList.contains('ftable-multiselect-checkbox')) {
3068
- document.activeElement.click();
3069
- }
3070
- }
3071
- });
3072
-
3073
- // Handle clicks outside
3074
- dropdownOverlay.addEventListener('click', (event) => {
3075
- if (event.target === dropdownOverlay) {
3076
- closeDropdown();
3077
- }
3078
- });
3079
-
3080
- // Reposition on scroll/resize
3081
- const repositionHandler = () => positionDropdown();
3082
- const scrollHandler = (e) => {
3083
- if (dropdown && dropdown.contains(e.target)) {
3084
- return; // Allow scrolling inside dropdown
3085
- }
3086
- positionDropdown();
3087
- };
3088
- const selectedResizeObserver = new ResizeObserver(() => {
3089
- positionDropdown();
3090
- });
3091
- window.addEventListener('scroll', scrollHandler, true);
3092
- window.addEventListener('resize', repositionHandler);
3093
- selectedResizeObserver.observe(selectedDisplay);
3094
-
3095
- // Store cleanup function
3096
- container._cleanupHandlers = () => {
3097
- window.removeEventListener('scroll', scrollHandler, true);
3098
- window.removeEventListener('resize', repositionHandler);
3099
- selectedResizeObserver.disconnect();
3100
- };
3101
- }
3102
- };
3103
-
3104
- display.addEventListener('click', toggleDropdown);
3105
- toggleBtn.addEventListener('click', toggleDropdown);
3106
- display.addEventListener('keydown', (e) => {
3107
- if (e.key === 'ArrowDown' || e.key === 'Enter') {
3108
- e.preventDefault();
3109
- toggleDropdown();
3110
- }
3111
- });
3112
-
3113
- // Add reset method to container
3114
- container.resetMultiSelect = () => {
3115
- selectedValues.clear();
3116
- checkboxMap.forEach(checkbox => {
3117
- checkbox.checked = false;
3118
- });
3119
- closeDropdown();
3120
- updateDisplay();
3121
- };
3122
-
3123
- // Clean up when container is removed from DOM
3124
- const observer = new MutationObserver((mutations) => {
3125
- mutations.forEach((mutation) => {
3126
- mutation.removedNodes.forEach((node) => {
3127
- if (node === container || node.contains && node.contains(container)) {
3128
- closeDropdown();
3129
- observer.disconnect();
3130
- }
3131
- });
3132
- });
2932
+ const livesearch = field.livesearch ?? false;
2933
+
2934
+ return this.formBuilder._buildCustomMultiSelect({
2935
+ hiddenSelectId: fieldSearchName,
2936
+ hiddenSelectName: attributes['data-field-name'] || fieldSearchName,
2937
+ extraClasses: 'ftable-multiselect-search ftable-toolbarsearch',
2938
+ containerDataFieldName: attributes['data-field-name'] || fieldSearchName,
2939
+ hiddenSelectAttributes: attributes,
2940
+ optionsSource: optionsSource,
2941
+ initialValues: [],
2942
+ placeholderText: field.searchPlaceholder || field.placeholder
2943
+ || this.options.messages?.multiSelectPlaceholder
2944
+ || 'Click to select options...',
2945
+ livesearch,
2946
+ onChangeExtra: (hiddenSelect) => {
2947
+ hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
2948
+ },
2949
+ buildHiddenSelectOnUpdate: false, // search mode: options pre-populated
3133
2950
  });
3134
-
3135
- // Start observing once container is in the DOM
3136
- setTimeout(() => {
3137
- if (container.parentNode) {
3138
- observer.observe(container.parentNode, { childList: true, subtree: true });
3139
- }
3140
- }, 0);
3141
-
3142
- // Initialize
3143
- updateDisplay();
3144
-
3145
- return container;
3146
2951
  }
3147
-
3148
2952
  async createDatalistForSearch(fieldName, field) {
3149
2953
  const fieldSearchName = 'ftable-toolbarsearch-' + fieldName;
3150
2954
 
@@ -3702,6 +3506,7 @@ class FTable extends FTableEventEmitter {
3702
3506
 
3703
3507
  this.columnList.forEach(fieldName => {
3704
3508
  const field = this.options.fields[fieldName];
3509
+ if (field.action) return; // Action columns don't appear in column picker
3705
3510
  const isVisible = field.visibility !== 'hidden';
3706
3511
  const isFixed = field.visibility === 'fixed';
3707
3512
  const isSeparator = field.visibility === 'separator';
@@ -3993,9 +3798,18 @@ class FTable extends FTableEventEmitter {
3993
3798
  const searchFields = [];
3994
3799
 
3995
3800
  Object.entries(this.state.searchQueries).forEach(([fieldName, query]) => {
3996
- if (query !== '') { // Double check it's not empty
3997
- queries.push(query);
3998
- searchFields.push(fieldName);
3801
+ if (Array.isArray(query)) {
3802
+ query.forEach(value => {
3803
+ if (value !== '' && value != null) {
3804
+ queries.push(value);
3805
+ searchFields.push(fieldName);
3806
+ }
3807
+ });
3808
+ } else {
3809
+ if (query !== '') {
3810
+ queries.push(query);
3811
+ searchFields.push(fieldName);
3812
+ }
3999
3813
  }
4000
3814
  });
4001
3815
 
@@ -4105,26 +3919,45 @@ class FTable extends FTableEventEmitter {
4105
3919
  // Store record data
4106
3920
  row.recordData = record;
4107
3921
 
4108
- // Add selecting checkbox if enabled
4109
- if (this.options.selecting && this.options.selectingCheckboxes) {
3922
+ // Add selecting checkbox if enabled (only if not user-placed)
3923
+ if (this.options.selecting && this.options.selectingCheckboxes && !this._userPlacedActions.has('select')) {
4110
3924
  this.addSelectingCell(row);
4111
3925
  }
4112
3926
 
4113
- // Add data cells
3927
+ // Add data cells (including user-placed action columns)
4114
3928
  this.columnList.forEach(fieldName => {
4115
- this.addDataCell(row, record, fieldName);
3929
+ const field = this.options.fields[fieldName];
3930
+ if (field.action) {
3931
+ // Render inline action cell
3932
+ switch (field.action) {
3933
+ case 'select':
3934
+ this.addSelectingCell(row);
3935
+ break;
3936
+ case 'update':
3937
+ this.addEditCell(row);
3938
+ break;
3939
+ case 'clone':
3940
+ this.addCloneCell(row);
3941
+ break;
3942
+ case 'delete':
3943
+ this.addDeleteCell(row);
3944
+ break;
3945
+ }
3946
+ } else {
3947
+ this.addDataCell(row, record, fieldName);
3948
+ }
4116
3949
  });
4117
3950
 
4118
- // Add action cells
4119
- if (this.options.actions.updateAction) {
3951
+ // Add default action cells only if not user-placed
3952
+ if (this.options.actions.updateAction && !this._userPlacedActions.has('update')) {
4120
3953
  this.addEditCell(row);
4121
3954
  }
4122
3955
 
4123
- if (this.options.actions.cloneAction) {
3956
+ if (this.options.actions.cloneAction && !this._userPlacedActions.has('clone')) {
4124
3957
  this.addCloneCell(row);
4125
3958
  }
4126
3959
 
4127
- if (this.options.actions.deleteAction) {
3960
+ if (this.options.actions.deleteAction && !this._userPlacedActions.has('delete')) {
4128
3961
  this.addDeleteCell(row);
4129
3962
  }
4130
3963