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