slim-select 3.3.0 → 3.4.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.
@@ -1658,4 +1658,220 @@ describe('render module', () => {
1658
1658
  expect(render.content.main.classList.contains(render.classes.dirBelow)).toBe(true)
1659
1659
  })
1660
1660
  })
1661
+
1662
+ describe('setContentPosition (via moveContentBelow)', () => {
1663
+ // Helper: mock getBoundingClientRect on an element to return specific values
1664
+ function mockRect(el: HTMLElement, rect: Partial<DOMRect>) {
1665
+ el.getBoundingClientRect = vi.fn(
1666
+ () =>
1667
+ ({
1668
+ top: 0,
1669
+ left: 0,
1670
+ right: 0,
1671
+ bottom: 0,
1672
+ width: 0,
1673
+ height: 0,
1674
+ x: 0,
1675
+ y: 0,
1676
+ toJSON: () => {},
1677
+ ...rect
1678
+ }) as DOMRect
1679
+ )
1680
+ }
1681
+
1682
+ test('absolute (default): content aligns below trigger using document coords', () => {
1683
+ // Simulate trigger at viewport (100, 50) with height 40 and width 200
1684
+ mockRect(render.main.main, { top: 100, left: 50, height: 40, width: 200 })
1685
+
1686
+ // No scroll
1687
+ Object.defineProperty(window, 'scrollY', { value: 0, writable: true })
1688
+ Object.defineProperty(window, 'scrollX', { value: 0, writable: true })
1689
+
1690
+ render.settings.contentPosition = 'absolute'
1691
+ render.moveContentBelow()
1692
+
1693
+ // top = viewportTop(100) + scrollY(0) + height(40) = 140
1694
+ // left = viewportLeft(50) + scrollX(0) = 50
1695
+ expect(render.content.main.style.top).toBe('140px')
1696
+ expect(render.content.main.style.left).toBe('50px')
1697
+ expect(render.content.main.style.width).toBe('200px')
1698
+ })
1699
+
1700
+ test('absolute: content accounts for page scroll', () => {
1701
+ // Trigger at viewport (100, 50) but page is scrolled down 300px and right 20px
1702
+ mockRect(render.main.main, { top: 100, left: 50, height: 40, width: 200 })
1703
+
1704
+ Object.defineProperty(window, 'scrollY', { value: 300, writable: true })
1705
+ Object.defineProperty(window, 'scrollX', { value: 20, writable: true })
1706
+
1707
+ render.settings.contentPosition = 'absolute'
1708
+ render.moveContentBelow()
1709
+
1710
+ // top = viewportTop(100) + scrollY(300) + height(40) = 440
1711
+ // left = viewportLeft(50) + scrollX(20) = 70
1712
+ expect(render.content.main.style.top).toBe('440px')
1713
+ expect(render.content.main.style.left).toBe('70px')
1714
+ expect(render.content.main.style.width).toBe('200px')
1715
+ })
1716
+
1717
+ test('absolute: content aligns correctly when trigger is near top of page', () => {
1718
+ // Trigger is at very top, no scroll
1719
+ mockRect(render.main.main, { top: 0, left: 10, height: 30, width: 150 })
1720
+
1721
+ Object.defineProperty(window, 'scrollY', { value: 0, writable: true })
1722
+ Object.defineProperty(window, 'scrollX', { value: 0, writable: true })
1723
+
1724
+ render.settings.contentPosition = 'absolute'
1725
+ render.moveContentBelow()
1726
+
1727
+ // top = 0 + 0 + 30 = 30
1728
+ // left = 10 + 0 = 10
1729
+ expect(render.content.main.style.top).toBe('30px')
1730
+ expect(render.content.main.style.left).toBe('10px')
1731
+ expect(render.content.main.style.width).toBe('150px')
1732
+ })
1733
+
1734
+ test('fixed: content uses viewport coords without scroll offset', () => {
1735
+ // Same trigger position and scroll as the absolute+scroll test
1736
+ mockRect(render.main.main, { top: 100, left: 50, height: 40, width: 200 })
1737
+
1738
+ Object.defineProperty(window, 'scrollY', { value: 300, writable: true })
1739
+ Object.defineProperty(window, 'scrollX', { value: 20, writable: true })
1740
+
1741
+ render.settings.contentPosition = 'fixed'
1742
+ render.moveContentBelow()
1743
+
1744
+ // Fixed ignores scroll: top = viewportTop(100) + height(40) = 140
1745
+ // left = viewportLeft(50) = 50
1746
+ expect(render.content.main.style.top).toBe('140px')
1747
+ expect(render.content.main.style.left).toBe('50px')
1748
+ expect(render.content.main.style.width).toBe('200px')
1749
+ })
1750
+
1751
+ test('relative: content position styles are not set', () => {
1752
+ mockRect(render.main.main, { top: 100, left: 50, height: 40, width: 200 })
1753
+
1754
+ render.settings.contentPosition = 'relative'
1755
+
1756
+ // Reset styles to confirm they aren't changed
1757
+ render.content.main.style.top = ''
1758
+ render.content.main.style.left = ''
1759
+ render.content.main.style.width = ''
1760
+
1761
+ render.moveContentBelow()
1762
+
1763
+ // setContentPosition returns early for relative, so no inline positioning
1764
+ expect(render.content.main.style.top).toBe('')
1765
+ expect(render.content.main.style.left).toBe('')
1766
+ expect(render.content.main.style.width).toBe('')
1767
+ })
1768
+
1769
+ test('absolute vs fixed diverge only by scroll offset', () => {
1770
+ const triggerRect = { top: 200, left: 80, height: 36, width: 250 }
1771
+
1772
+ Object.defineProperty(window, 'scrollY', { value: 500, writable: true })
1773
+ Object.defineProperty(window, 'scrollX', { value: 0, writable: true })
1774
+
1775
+ // Absolute
1776
+ mockRect(render.main.main, triggerRect)
1777
+ render.settings.contentPosition = 'absolute'
1778
+ render.moveContentBelow()
1779
+ const absTop = render.content.main.style.top
1780
+ const absLeft = render.content.main.style.left
1781
+
1782
+ // Fixed
1783
+ mockRect(render.main.main, triggerRect)
1784
+ render.settings.contentPosition = 'fixed'
1785
+ render.moveContentBelow()
1786
+ const fixedTop = render.content.main.style.top
1787
+ const fixedLeft = render.content.main.style.left
1788
+
1789
+ // Absolute top should be exactly scrollY more than fixed top
1790
+ expect(parseFloat(absTop)).toBe(parseFloat(fixedTop) + 500)
1791
+ // Left should differ by scrollX (which is 0 here, so equal)
1792
+ expect(parseFloat(absLeft)).toBe(parseFloat(fixedLeft))
1793
+ })
1794
+
1795
+ describe('contentWidth setting', () => {
1796
+ beforeEach(() => {
1797
+ mockRect(render.main.main, { top: 100, left: 50, height: 40, width: 200 })
1798
+ Object.defineProperty(window, 'scrollY', { value: 0, writable: true })
1799
+ Object.defineProperty(window, 'scrollX', { value: 0, writable: true })
1800
+ render.settings.contentPosition = 'absolute'
1801
+ })
1802
+
1803
+ test('default (empty): width matches trigger', () => {
1804
+ render.settings.contentWidth = ''
1805
+ render.moveContentBelow()
1806
+
1807
+ expect(render.content.main.style.width).toBe('200px')
1808
+ expect(render.content.main.style.minWidth).toBe('')
1809
+ expect(render.content.main.style.maxWidth).toBe('')
1810
+ })
1811
+
1812
+ test('exact value: sets width to given value', () => {
1813
+ render.settings.contentWidth = '500px'
1814
+ render.moveContentBelow()
1815
+
1816
+ expect(render.content.main.style.width).toBe('500px')
1817
+ expect(render.content.main.style.minWidth).toBe('')
1818
+ expect(render.content.main.style.maxWidth).toBe('')
1819
+ })
1820
+
1821
+ test('> prefix: sets min-width, no fixed width', () => {
1822
+ render.settings.contentWidth = '>300px'
1823
+ render.moveContentBelow()
1824
+
1825
+ expect(render.content.main.style.minWidth).toBe('300px')
1826
+ expect(render.content.main.style.width).toBe('')
1827
+ expect(render.content.main.style.maxWidth).toBe('')
1828
+ })
1829
+
1830
+ test('< prefix: sets max-width, no fixed width', () => {
1831
+ render.settings.contentWidth = '<600px'
1832
+ render.moveContentBelow()
1833
+
1834
+ expect(render.content.main.style.maxWidth).toBe('600px')
1835
+ expect(render.content.main.style.width).toBe('')
1836
+ expect(render.content.main.style.minWidth).toBe('')
1837
+ })
1838
+
1839
+ test('exact value with percentage', () => {
1840
+ render.settings.contentWidth = '100%'
1841
+ render.moveContentBelow()
1842
+
1843
+ expect(render.content.main.style.width).toBe('100%')
1844
+ expect(render.content.main.style.minWidth).toBe('')
1845
+ expect(render.content.main.style.maxWidth).toBe('')
1846
+ })
1847
+
1848
+ test('clears previous width styles when switching modes', () => {
1849
+ // First set min-width
1850
+ render.settings.contentWidth = '>300px'
1851
+ render.moveContentBelow()
1852
+ expect(render.content.main.style.minWidth).toBe('300px')
1853
+
1854
+ // Now switch to exact width — min-width should be cleared
1855
+ render.settings.contentWidth = '500px'
1856
+ render.moveContentBelow()
1857
+ expect(render.content.main.style.width).toBe('500px')
1858
+ expect(render.content.main.style.minWidth).toBe('')
1859
+ expect(render.content.main.style.maxWidth).toBe('')
1860
+ })
1861
+
1862
+ test('clears previous width styles when switching to default', () => {
1863
+ // First set max-width
1864
+ render.settings.contentWidth = '<600px'
1865
+ render.moveContentBelow()
1866
+ expect(render.content.main.style.maxWidth).toBe('600px')
1867
+
1868
+ // Now switch to default — max-width should be cleared, width matches trigger
1869
+ render.settings.contentWidth = ''
1870
+ render.moveContentBelow()
1871
+ expect(render.content.main.style.width).toBe('200px')
1872
+ expect(render.content.main.style.minWidth).toBe('')
1873
+ expect(render.content.main.style.maxWidth).toBe('')
1874
+ })
1875
+ })
1876
+ })
1661
1877
  })
@@ -1614,26 +1614,52 @@ export default class Render {
1614
1614
  return
1615
1615
  }
1616
1616
 
1617
+ // getBoundingClientRect() returns viewport-relative coordinates.
1617
1618
  const containerRect = this.main.main.getBoundingClientRect()
1618
1619
  let top: number
1619
1620
  let left: number
1620
1621
 
1621
1622
  if (this.settings.contentPosition === 'fixed') {
1622
- // Fixed positioning - use viewport coordinates directly
1623
+ // position:fixed is relative to the viewport, so viewport coords work directly.
1623
1624
  top = containerRect.top + containerRect.height
1624
1625
  left = containerRect.left
1625
1626
  } else {
1626
- // Absolute positioning - calculate relative to offsetParent
1627
- const offsetParent = this.content.main.offsetParent as HTMLElement
1628
- const offsetParentRect = offsetParent ? offsetParent.getBoundingClientRect() : { top: 0, left: 0 }
1629
-
1630
- top = containerRect.top - offsetParentRect.top + containerRect.height - (offsetParent?.clientTop || 0)
1631
- left = containerRect.left - offsetParentRect.left - (offsetParent?.clientLeft || 0)
1627
+ // position:absolute is relative to the containing block. By default the content
1628
+ // is appended to document.body (static), so the containing block is the initial
1629
+ // containing block (document origin). We must convert viewport coords to document
1630
+ // coords by adding scroll offsets. Using offsetParent subtraction was unreliable
1631
+ // because offsetParent for body children varies across browsers (body vs html)
1632
+ // and body margin caused misalignment.
1633
+ top = containerRect.top + window.scrollY + containerRect.height
1634
+ left = containerRect.left + window.scrollX
1632
1635
  }
1633
1636
 
1634
1637
  this.content.main.style.top = top + 'px'
1635
1638
  this.content.main.style.left = left + 'px'
1636
- this.content.main.style.width = containerRect.width + 'px'
1639
+
1640
+ // Apply content width based on contentWidth setting
1641
+ // "" → width matches trigger (default/current behavior)
1642
+ // "500px" → exact width of 500px
1643
+ // ">500px" → min-width of 500px, width auto (content can grow)
1644
+ // "<500px" → max-width of 500px, width auto (content can shrink)
1645
+ const cw = this.settings.contentWidth
1646
+ this.content.main.style.width = ''
1647
+ this.content.main.style.minWidth = ''
1648
+ this.content.main.style.maxWidth = ''
1649
+
1650
+ if (!cw) {
1651
+ // Default: match trigger width exactly
1652
+ this.content.main.style.width = containerRect.width + 'px'
1653
+ } else if (cw.startsWith('>')) {
1654
+ // Min-width mode: at least this wide, can grow to fit content
1655
+ this.content.main.style.minWidth = cw.slice(1)
1656
+ } else if (cw.startsWith('<')) {
1657
+ // Max-width mode: no wider than this, content wraps if longer
1658
+ this.content.main.style.maxWidth = cw.slice(1)
1659
+ } else {
1660
+ // Exact width
1661
+ this.content.main.style.width = cw
1662
+ }
1637
1663
  }
1638
1664
 
1639
1665
  public moveContentAbove(): void {
@@ -36,7 +36,8 @@ const defaultSettings: { [key: string]: any } = {
36
36
  maxSelected: 1000,
37
37
  timeoutDelay: 200,
38
38
  maxValuesShown: 20,
39
- maxValuesMessage: '{number} selected'
39
+ maxValuesMessage: '{number} selected',
40
+ contentWidth: ''
40
41
  }
41
42
 
42
43
  describe('Settings module', () => {
@@ -25,6 +25,7 @@ export default class Settings {
25
25
  public closeOnSelect: boolean
26
26
  public contentLocation: HTMLElement | null
27
27
  public contentPosition: 'relative' | 'absolute' | 'fixed'
28
+ public contentWidth: string
28
29
  public openPosition: 'auto' | 'up' | 'down'
29
30
  public placeholderText: string
30
31
  public allowDeselect: boolean
@@ -60,6 +61,7 @@ export default class Settings {
60
61
  this.closeOnSelect = settings.closeOnSelect !== undefined ? settings.closeOnSelect : true
61
62
  this.contentLocation = settings.contentLocation || document.body
62
63
  this.contentPosition = settings.contentPosition || 'absolute'
64
+ this.contentWidth = settings.contentWidth || ''
63
65
  this.openPosition = settings.openPosition || 'auto' // options: auto, up, down
64
66
  this.placeholderText = settings.placeholderText !== undefined ? settings.placeholderText : 'Select Value'
65
67
  this.allowDeselect = settings.allowDeselect !== undefined ? settings.allowDeselect : false