@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.
- package/.aiexclude +3 -0
- package/.vscode/settings.json +4 -1
- package/build/src/elements/autocomplete/internals/autocomplete.d.ts +53 -5
- package/build/src/elements/autocomplete/internals/autocomplete.d.ts.map +1 -1
- package/build/src/elements/autocomplete/internals/autocomplete.js +156 -33
- package/build/src/elements/autocomplete/internals/autocomplete.js.map +1 -1
- package/build/src/elements/highlight/MarkedHighlight.d.ts +2 -1
- package/build/src/elements/highlight/MarkedHighlight.d.ts.map +1 -1
- package/build/src/elements/highlight/MarkedHighlight.js +14 -11
- package/build/src/elements/highlight/MarkedHighlight.js.map +1 -1
- package/demo/elements/autocomplete/index.html +40 -0
- package/demo/elements/autocomplete/index.ts +52 -4
- package/package.json +1 -1
- package/src/elements/autocomplete/internals/autocomplete.ts +136 -32
- package/src/elements/highlight/MarkedHighlight.ts +16 -13
- package/test/elements/autocomplete/autocomplete-input.spec.ts +210 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
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
|
})
|