@api-client/ui 0.2.4 → 0.2.6

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.
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-unused-expressions */
2
- import { fixture, expect, html, oneEvent, nextFrame, aTimeout } from '@open-wc/testing'
2
+ import { fixture, expect, html, oneEvent, nextFrame, aTimeout, assert } from '@open-wc/testing'
3
3
  import sinon from 'sinon'
4
4
 
5
5
  import { AutocompleteInput } from '../../../src/elements/autocomplete/autocomplete-input.js'
@@ -16,7 +16,7 @@ describe('AutocompleteInput', () => {
16
16
  return fixture(html`
17
17
  <autocomplete-input>
18
18
  <input id="test-input" slot="input" type="text" placeholder="Search..." />
19
- <ui-listbox slot="suggestions">
19
+ <ui-listbox slot="suggestions" aria-label="Suggestions">
20
20
  <ui-list-item data-value="apple">Apple</ui-list-item>
21
21
  <ui-list-item data-value="banana">Banana</ui-list-item>
22
22
  <ui-list-item data-value="cherry" data-index="value customField" data-custom-field="Sweet Cherry"
@@ -31,7 +31,7 @@ describe('AutocompleteInput', () => {
31
31
  return fixture(html`
32
32
  <autocomplete-input>
33
33
  <input slot="input" type="text" placeholder="Search..." />
34
- <ui-listbox slot="suggestions"></ui-listbox>
34
+ <ui-listbox slot="suggestions" aria-label="Suggestions"></ui-listbox>
35
35
  </autocomplete-input>
36
36
  `)
37
37
  }
@@ -45,13 +45,11 @@ describe('AutocompleteInput', () => {
45
45
  }
46
46
 
47
47
  function getSlottedInput(el: AutocompleteInput): HTMLInputElement | null {
48
- const slot = el.shadowRoot!.querySelector('slot[name="input"]') as HTMLSlotElement
49
- return (slot.assignedElements({ flatten: true })[0] as HTMLInputElement) || null
48
+ return el.querySelector('[slot="input"]') as HTMLInputElement | null
50
49
  }
51
50
 
52
51
  function getSlottedSuggestions(el: AutocompleteInput): MdListbox | null {
53
- const slot = el.shadowRoot!.querySelector('slot[name="suggestions"]') as HTMLSlotElement
54
- return (slot.assignedElements({ flatten: true })[0] as MdListbox) || null
52
+ return el.querySelector('[slot="suggestions"]') as MdListbox | null
55
53
  }
56
54
 
57
55
  function getSuggestionItems(suggestionsEl: MdListbox): MdListItem[] {
@@ -85,14 +83,15 @@ describe('AutocompleteInput', () => {
85
83
  await el.updateComplete
86
84
  await nextFrame() // Allow for internal state updates
87
85
 
88
- const input = getSlottedInput(el)
89
- const suggestions = getSlottedSuggestions(el)
86
+ const input = getSlottedInput(el)!
87
+ const suggestions = getSlottedSuggestions(el)!
90
88
 
91
- expect(input?.id).to.match(/^autocomplete-input-/)
89
+ expect(input.id).to.match(/^autocomplete-input-/)
92
90
  // @ts-expect-error _inputId is a protected member
93
91
  const internalInputId = el.inputId
94
- expect(input?.style.getPropertyValue('anchor-name')).to.equal(`--${internalInputId}`)
95
- expect(suggestions?.style.getPropertyValue('position-anchor')).to.equal(`--${internalInputId}`)
92
+ expect(input.style.getPropertyValue('anchor-name')).to.equal(`--${internalInputId}`, 'input has anchor name')
93
+ const anchorValue = getComputedStyle(suggestions).getPropertyValue('position-anchor')
94
+ expect(anchorValue).to.equal(`--${internalInputId}`, 'suggestions element has position-anchor')
96
95
  })
97
96
 
98
97
  it('popover is initially closed', async () => {
@@ -390,7 +389,8 @@ describe('AutocompleteInput', () => {
390
389
  const slottedSuggestions = getSlottedSuggestions(el)
391
390
  expect(slottedSuggestions).to.equal(suggestionsEl)
392
391
  expect(suggestionsEl.popover).to.equal('manual')
393
- expect(suggestionsEl.style.getPropertyValue('position-anchor')).to.equal(`--${input.id}`)
392
+ const anchorValue = getComputedStyle(suggestionsEl).getPropertyValue('position-anchor')
393
+ expect(anchorValue).to.equal(`--${input.id}`)
394
394
  })
395
395
 
396
396
  it('re-filters when suggestion items change (via itemschange event)', async () => {
@@ -445,4 +445,201 @@ describe('AutocompleteInput', () => {
445
445
  expect(suggestions.matches(':popover-open')).to.be.false
446
446
  })
447
447
  })
448
+
449
+ describe('Popover Positioning', () => {
450
+ let el: AutocompleteInput
451
+ let input: HTMLInputElement
452
+ let suggestionsBox: MdListbox
453
+ let getBoundingClientRectStub: sinon.SinonStub
454
+
455
+ // Define a standard popover height for consistent threshold calculation
456
+ const popoverVisibleHeight = 200 // px, used for scrollHeight
457
+
458
+ beforeEach(async () => {
459
+ el = await basicFixture() // This fixture has suggestions
460
+ await el.updateComplete // Ensures inputRef is set up
461
+ input = getSlottedInput(el)!
462
+ suggestionsBox = getSlottedSuggestions(el)!
463
+
464
+ // Set a known height for the suggestions box to make its scrollHeight predictable
465
+ suggestionsBox.style.height = `${popoverVisibleHeight}px`
466
+ suggestionsBox.style.display = 'block' // Ensure it's not display:none from other CSS
467
+ suggestionsBox.style.overflow = 'auto'
468
+ // Ensure it has some content to have a scrollHeight if not stubbed
469
+ if (getSuggestionItems(suggestionsBox).length === 0) {
470
+ const item = document.createElement('ui-list-item')
471
+ item.textContent = 'Dummy Item'
472
+ suggestionsBox.appendChild(item)
473
+ }
474
+ await nextFrame() // Allow DOM to update for scrollHeight calculation
475
+
476
+ // @ts-expect-error accessing protected member `inputRef`
477
+ const anchorEl = el.inputRef as HTMLElement // Default anchor
478
+ if (!anchorEl) {
479
+ throw new Error('el.inputRef was not initialized')
480
+ }
481
+ getBoundingClientRectStub = sinon.stub(anchorEl, 'getBoundingClientRect')
482
+ })
483
+
484
+ afterEach(() => {
485
+ sinon.restore() // Restores window.innerHeight and the getBoundingClientRectStub
486
+ })
487
+
488
+ function setAnchorPosition(
489
+ anchorRect: Partial<DOMRect & { height: number; width: number }>,
490
+ viewportHeight: number
491
+ ) {
492
+ getBoundingClientRectStub.returns(anchorRect as DOMRect)
493
+ // Stub window.innerHeight for this call, will be restored by sinon.restore() in afterEach
494
+ sinon.stub(window, 'innerHeight').get(() => viewportHeight)
495
+ }
496
+
497
+ it('positions at bottom when ample space below', async () => {
498
+ // Anchor near top, viewport large. popoverThresholdHeight will be popoverVisibleHeight (200)
499
+ setAnchorPosition({ top: 50, bottom: 80, height: 30, x: 0, y: 50, width: 100, left: 0, right: 100 }, 800)
500
+ // spaceBelow = 800 - 80 = 720. 720 > 200.
501
+
502
+ input.focus() // Triggers openSuggestions
503
+ await el.updateComplete // For positionArea state change and re-render
504
+ await nextFrame() // For DOM to reflect changes
505
+
506
+ expect(el.positionArea).to.equal('bottom')
507
+ })
508
+
509
+ it('positions at top when insufficient space below but sufficient space above', async () => {
510
+ // popoverThresholdHeight = 200
511
+ // Anchor bottom: 750. Viewport: 800. spaceBelow = 50. (50 < 200)
512
+ // Anchor top: 500. spaceAbove = 500. (500 > 50) && (500 > 200) -> true
513
+ setAnchorPosition({ top: 500, bottom: 750, height: 250, x: 0, y: 500, width: 100, left: 0, right: 100 }, 800)
514
+
515
+ input.focus()
516
+ await el.updateComplete
517
+ await nextFrame()
518
+
519
+ expect(el.positionArea).to.equal('top')
520
+ })
521
+
522
+ it('positions at top when insufficient space below and more space above', async () => {
523
+ // popoverThresholdHeight = 200
524
+ // spaceBelow = 50 (anchor.bottom = viewportHeight - 50)
525
+ // spaceAbove = 100 (anchor.top = 100)
526
+ // viewportHeight = anchor.top + anchor.height + spaceBelow = 100 + 30 + 50 = 180
527
+ // Test case: spaceBelow=50, spaceAbove=100. popoverThresholdHeight=200.
528
+ // (50 < 200) is true.
529
+ // (100 > 50) is true.
530
+ // (100 > 200) is false. -> first 'top' condition fails.
531
+ // Second 'top' condition: (spaceBelow < popoverThresholdHeight && spaceAbove > spaceBelow) -> true.
532
+ setAnchorPosition({ top: 100, bottom: 130, height: 30, x: 0, y: 100, width: 100, left: 0, right: 100 }, 180)
533
+
534
+ input.focus()
535
+ await el.updateComplete
536
+ await nextFrame()
537
+
538
+ expect(el.positionArea).to.equal('top')
539
+ })
540
+
541
+ it('positions at bottom when insufficient space below and also insufficient (or less) space above', async () => {
542
+ // popoverThresholdHeight = 200
543
+ // spaceBelow = 50 (anchor.bottom = viewportHeight - 50)
544
+ // spaceAbove = 40 (anchor.top = 40)
545
+ // viewportHeight = anchor.top + anchor.height + spaceBelow = 40 + 30 + 50 = 120
546
+ // Test case: spaceBelow=50, spaceAbove=40.
547
+ // (50 < 200) is true.
548
+ // (40 > 50) is false. -> both 'top' conditions fail. Defaults to 'bottom'.
549
+ setAnchorPosition({ top: 40, bottom: 70, height: 30, x: 0, y: 40, width: 100, left: 0, right: 100 }, 120)
550
+
551
+ input.focus()
552
+ await el.updateComplete
553
+ await nextFrame()
554
+
555
+ expect(el.positionArea).to.equal('bottom')
556
+ })
557
+
558
+ it('uses fallback threshold of 150px if popover scrollHeight is 0', async () => {
559
+ // Stub scrollHeight to be 0 for this test.
560
+ const scrollHeightStub = sinon.stub(suggestionsBox, 'scrollHeight').get(() => 0)
561
+
562
+ // popoverThresholdHeight will be 150 (the fallback).
563
+ // Scenario: spaceBelow = 100, spaceAbove = 200.
564
+ // (100 < 150) is true.
565
+ // (200 > 100) is true. (200 > 150) is true. -> Should be 'top'.
566
+ setAnchorPosition({ top: 500, bottom: 700, height: 200, x: 0, y: 500, width: 100, left: 0, right: 100 }, 800)
567
+ // spaceBelow = 800 - 700 = 100
568
+ // spaceAbove = 500 (mistake in manual calc above, should be 500)
569
+ // Corrected: spaceBelow = 100. spaceAbove = 500. Threshold = 150.
570
+ // (100 < 150) -> true
571
+ // (500 > 100) -> true. (500 > 150) -> true. Result: 'top'.
572
+
573
+ input.focus()
574
+ await el.updateComplete
575
+ await nextFrame()
576
+
577
+ expect(el.positionArea).to.equal('top')
578
+ scrollHeightStub.restore()
579
+ })
580
+
581
+ it('uses slotted anchor for positioning if provided', async () => {
582
+ // Need to create a new fixture for this specific setup
583
+ el = await fixture(html`
584
+ <autocomplete-input>
585
+ <div slot="anchor" id="custom-anchor" style="height: 20px; width: 100px; border: 1px solid red;"></div>
586
+ <input slot="input" type="text" />
587
+ <ui-listbox slot="suggestions" style="height: ${popoverVisibleHeight}px; overflow: auto;">
588
+ <ui-list-item>Item 1</ui-list-item>
589
+ </ui-listbox>
590
+ </autocomplete-input>
591
+ `)
592
+ await el.updateComplete
593
+ input = getSlottedInput(el)! // Still need to focus input
594
+ const customAnchor = el.querySelector('#custom-anchor') as HTMLElement
595
+
596
+ getBoundingClientRectStub.restore() // remove stub from default input anchor
597
+ getBoundingClientRectStub = sinon.stub(customAnchor, 'getBoundingClientRect')
598
+
599
+ // Scenario: custom anchor is near bottom, should open top. popoverThresholdHeight = 200.
600
+ setAnchorPosition({ top: 720, bottom: 750, height: 30, x: 0, y: 720, width: 100, left: 0, right: 100 }, 800)
601
+
602
+ input.focus() // Triggers openSuggestions
603
+ await el.updateComplete
604
+ await nextFrame()
605
+
606
+ expect(el.positionArea).to.equal('top')
607
+ expect(getBoundingClientRectStub.called).to.be.true
608
+ })
609
+ })
610
+
611
+ describe('Accessibility', () => {
612
+ it('is accessible when initially rendered', async () => {
613
+ const el = await basicFixture()
614
+ await el.updateComplete
615
+ await assert.isAccessible(el)
616
+ })
617
+
618
+ it('is accessible when popover is open', async () => {
619
+ const el = await basicFixture()
620
+ await el.updateComplete
621
+ const input = getSlottedInput(el)!
622
+
623
+ input.focus() // Opens popover
624
+ await nextFrame() // Allow popover to open
625
+ await el.updateComplete // Ensure state updates related to opening are done
626
+
627
+ await assert.isAccessible(el)
628
+ })
629
+
630
+ it('is accessible when popover is open and an item is highlighted', async () => {
631
+ const el = await basicFixture()
632
+ await el.updateComplete
633
+ const input = getSlottedInput(el)!
634
+
635
+ input.focus() // Opens popover
636
+ await nextFrame()
637
+
638
+ // Simulate highlighting the first item
639
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, composed: true }))
640
+ await aTimeout(0) // for rAF in handleKeydown
641
+ await nextFrame() // for highlight to apply
642
+ await assert.isAccessible(el)
643
+ })
644
+ })
448
645
  })