Skip to content

Commit

Permalink
builds its own options list now
Browse files Browse the repository at this point in the history
  • Loading branch information
Chris Nelson committed Jan 29, 2025
1 parent f526c71 commit da5668f
Show file tree
Hide file tree
Showing 2 changed files with 107 additions and 94 deletions.
136 changes: 101 additions & 35 deletions src/autocomplete-input.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,100 @@
import Combobox from '@github/combobox-nav';
import debounce from './debounce.js';
import { LitElement, html } from 'lit';
import { LitElement, html, css } from 'lit';

export class AutocompleteInputElement extends LitElement {
static formAssociated = true;
static styles = css`
.input-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
input {
width: 100%;
padding-right: 30px; /* Make room for the cancel icon */
}
.cancel-icon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
font-size: 18px;
color: #666;
border: none;
background: none;
padding: 4px;
line-height: 1;
}
.cancel-icon:hover {
color: #333;
}
.display-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.edit-icon {
color: #666;
font-size: 14px;
display: inline-flex;
align-items: center;
}
.display-wrapper:hover .edit-icon {
color: #333;
}
`;

static properties = {
value: {},
name: {},
labelProperty: {
type: String,
attribute: 'label-property',
},
valueProperty: {
type: String,
attribute: 'value-property',
},
displayValue: {
type: String,
attribute: 'display-value',
},
items: { type: Array },
debounce: { type: Number },
minlength: { type: Number },
minLength: { type: Number, attribute: 'min-length' },
searchValue: { attribute: 'search-value' },
clearListOnSelect: { attribute: 'clear-list-on-select', type: Boolean },
open: { type: Boolean, converter: (value, _type) => value !== 'false' },
open: { type: Boolean},
}

constructor() {
super();
this.labelProperty = 'name';
this.valueProperty = 'id';
this.searchValue = '';
this.displayValue = 'Choose an organization';
this.debounce = 300;
this.minlength = 3;
this.minLength = 3;
this.items = [];
this.elementInternals = this.attachInternals();
this.addEventListener('click', (e) => {
if (!this.open) {
this.searchValue = '';
this.open = true;
}
});
this.addEventListener('focusout', (e) => {
console.log(e);
});
}

cancel() {
this.open = false;
setTimeout(() => this.dispatchEvent(new CustomEvent('autocomplete-close', {detail: {query: this.searchInput?.value}})));
this.items = [];
}

startSearch() {
this.open = true;
}

hasState(state) {
Expand All @@ -55,54 +115,56 @@ export class AutocompleteInputElement extends LitElement {
}

render() {
return html`${this.open ? html`
<input name="${this.name}" .value="${this.searchValue}" @keydown=${this.onKeyDown} part="input" autocomplete="off" @input=${debounce((e) => this.onSearch(e), this.debounce)}>
` : html`<slot></slot>`}
<slot name="list"></slot>
return this.open ? html`
<div class="input-wrapper">
<input name="${this.name}" .value="${this.searchValue}" @keydown=${this.onKeyDown} part="input" autocomplete="off" @input=${debounce((e) => this.onSearch(e), this.debounce)}>
<button class="cancel-icon" slot="cancel-icon" @click=${this.cancel} aria-label="Clear input">×</button>
</div>
${this.items.length > 0 ? html`
<ul part="list">
${this.items.map((item) => html`<li role="option" part="option" data-value="${item[this.valueProperty]}">${item[this.labelProperty]}</li>`)}
</ul>
` : ''}
` : html`
<div class="display-wrapper" @click=${this.startSearch}>
<span>${this.displayValue}</span>
<svg class="edit-icon" slot="edit-icon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>
</svg>
</div>
`;
}

onKeyDown(e) {
if (e.key == 'Escape') {
this.cancel();
}
console.log(e);
}

onClick(e) {
if (!this.open) {
this.searchValue = '';
this.open = true;
e.stopPropagation();
}
}

onSearch(e) {
if (this.searchInput.value.length >= this.minlength) {
if (this.searchInput.value.length >= this.minLength) {
this.elementInternals.states.add('searching');
this.dispatchEvent(
new CustomEvent('autocomplete-search', { detail: { query: this.searchInput.value } }));
new CustomEvent('autocomplete-search', { detail: { query: this.searchInput.value } }));
}
}

onCommit({ target }) {
this.open = false;
this.searchValue = target.dataset.label ? target.dataset.label : target.innerText;
this.displayValue = target.dataset.label ? target.dataset.label : target.innerText;
this.value = target.dataset.value;
if (this.elementInternals.form) {
this.elementInternals.setFormValue(target.dataset.value);
new FormData(this.elementInternals.form).forEach(console.debug);
}
if (this.clearListOnSelect) {
this.list.replaceChildren();
this.items = [];
}
this.dispatchEvent(new CustomEvent('autocomplete-commit', { detail: target.dataset, bubbles: true }));
}

get list() {
if (this.getAttribute('list')) {
return document.querySelector(`#${this.getAttribute('list')}`);
}
return this.listSlot.assignedElements().length > 0 ? this.listSlot.assignedElements()[0] : undefined;
return this.shadowRoot.querySelector('ul');
}

get listSlot() {
Expand All @@ -115,6 +177,10 @@ export class AutocompleteInputElement extends LitElement {
// when options appear, start intercepting keyboard events for navigation
this.combobox.start();
this.list.addEventListener('combobox-commit', (e) => this.onCommit(e));
this.list.addEventListener('combobox-select', (e) => {
this.list.querySelectorAll('li').forEach((li) => li.setAttribute('part', 'option'));
e.target.setAttribute('part', 'selected-option');
});
}
}

Expand Down
65 changes: 6 additions & 59 deletions test/autocomplete-input.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,13 @@ it('displays an input when opened', async () => {
const el = await fixture(`
<autocomplete-input name="foo" search-value="abc">bar</autocomplete-input>
`);
el.click();
el.shadowRoot.querySelector('div.display-wrapper').click();
await el.updated;
const searchInput = el.shadowRoot.querySelector('input');
expect(searchInput).to.exist;
expect(el.hasState('open')).to.be.true;
});

// not sure what's up here
xit('clears the previous value from the input when re-opened', async () => {
const el = await fixture(`
<autocomplete-input name="foo">bar</autocomplete-input>
`);
el.click();
await el.updated;
let searchInput = el.shadowRoot.querySelector('input');
searchInput.value = 'foo';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
await el.updated;
el.click();
await el.updated;
searchInput = el.shadowRoot.querySelector('input');
expect(searchInput.value).to.equal('');
});

it('emits an autocomplete-search event', async () => {
const el = await fixture(`
<autocomplete-input name="foo" open debounce="10"></autocomplete-input>
Expand Down Expand Up @@ -66,13 +49,10 @@ it('only dispatches search event when the mininum length is met', async () => {
describe('the combobox', () => {
it('builds a combobox and sends autocomplete-commit for a slotted list', async () => {
const el = await fixture(`
<autocomplete-input name="foo" open>
<ul slot="list">
<li role="option" data-value="foo">Foo</li>
</ul>
<autocomplete-input name="foo" open items='[{"id": "foo", "name": "Foo"}]'>
</autocomplete-input>
`);
const option = el.querySelector('li[data-value="foo"]');
const option = el.shadowRoot.querySelector('li[data-value="foo"]');
el.addEventListener('autocomplete-commit', (e) => {
console.debug(e.detail)
expect(e.detail.value).to.equal('foo');
Expand All @@ -83,34 +63,14 @@ describe('the combobox', () => {
it('sets values when an option is clicked', async () => {
const formElement = await fixture(`
<form>
<autocomplete-input name="foo" open>
<ul slot="list">
<li role="option" data-value="bar">Bar</li>
</ul>
<autocomplete-input name="foo" open items='[{"id": "bar", "name": "Bar"}]'>
</autocomplete-input>
</form>
`);
const option = formElement.querySelector('li[data-value="bar"]');
option.click();
expect(new FormData(formElement).get('foo')).to.eq('bar');
const autocompleteElement = formElement.querySelector('autocomplete-input');
expect(autocompleteElement.value).to.equal('bar');
});

it('clears options if requested to', async () => {
const formElement = await fixture(`
<form>
<autocomplete-input name="foo" open clear-list-on-select>
<ul slot="list">
<li role="option" data-value="bar">Bar</li>
</ul>
</autocomplete-input>
</form>
`);
const option = formElement.querySelector('li[data-value="bar"]');
const option = autocompleteElement.shadowRoot.querySelector('li[data-value="bar"]');
option.click();
const options = formElement.querySelectorAll('ul li');
expect(options).to.have.length(0);
expect(new FormData(formElement).get('foo')).to.eq('bar');
});

it('sets form value from value attribute', async () => {
Expand All @@ -126,17 +86,4 @@ describe('the combobox', () => {
expect(new FormData(formElement).get('foo')).to.eq('bar')
});

it('restores on escape', async () => {
const el = await fixture(`
<autocomplete-input name="foo" value="bar" display-value="Bar" open>
<ul slot="list">
<li role="option" data-value="bar">Bar</li>
</ul>
</autocomplete-input>
`);
const searchInput = el.shadowRoot.querySelector('input');
searchInput.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
const closeEvent = await oneEvent(el, 'autocomplete-close');
expect(closeEvent).to.exist;
});
});

0 comments on commit da5668f

Please sign in to comment.