Skip to content

Commit bd40a73

Browse files
ArathyKumaradamjohnsonbennypowers
authored
feat(search-input): pf-search-input element (#2899)
* chore: pf-search-input component created * chore: search input updated * chore: search input option added * chore: pf-search input created * chore: code cleanup * chore: search autocomplete style added * chore: close button functionality implemented * chore: CSS variables updated * chore: styles updated * chore: outside click implementation * chore: added pf-option instead of pf-search-input-option * chore: style issues resolved * chore: style issues resolved * chore: code cleanup * chore: code cleanup * chore: search icon fix * chore: Limit number of autocomplete options shown to the user * chore: selected suggestion styling removed * fix: resolved style issues * chore: resolved lint errors * chore: reverted combobox controller changes * chore: added static initialization block for outside click * chore: fix SSR issues * chore: removed mock data * chore: hide close button * chore: inline styles removed * chore: replaced svg with pf-icon * chore: pf-option selected background color variable added * chore: removed unnecessary style * feat(search-input): add pf-search-input element * chore: search change event updated * chore: replace native input with pf-text-input * chore: added search functionality on enter and space key press * chore: screenshot updated * chore: demo files updated * chore: updated readme * chore: removed duplicate style * chore: test cases added - draft * chore: draft spec file added * chore: spec file updated * chore: updated test cases * chore: style fix * chore: submit button variant added * chore: code cleanup * chore: submit button function updated * chore: search input with submit button variant added * chore: display listbox only when input is entered in the textbox * chore: test cases updated * chore: updated documentation * chore: updated arrow-down functionality * chore: updated documentation * chore: variable added for listbox max-height * chore: updated to css logical properties * chore: added negative tab index to prevent focus * Update elements/pf-search-input/docs/pf-search-input.md Co-authored-by: Adam Johnson <[email protected]> * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Adam Johnson <[email protected]> * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Adam Johnson <[email protected]> * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Adam Johnson <[email protected]> * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Adam Johnson <[email protected]> * chore: resolved tab indentation issues * chore: documentation update * chore: set selected option function updated * chore: set focus back to toggle input on clicking close button * Update elements/pf-search-input/docs/pf-search-input.md Co-authored-by: Adam Johnson <[email protected]> * chore: added type button for hidden button * docs: changeset * chore: update cem dependency * style: formatting, docs * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Benny Powers - עם ישראל חי! <[email protected]> * fix: selected property removed * Update elements/pf-search-input/pf-search-input.ts Co-authored-by: Benny Powers - עם ישראל חי! <[email protected]> * fix: move focusout handling into ComboboxController * fix: ssr issue * chore: extend toggle button to support close functionality * fix: updated this.value state management * chore: added test cases for search input in disabled state * chore: test cases added for close button functionality * chore: test cases added for outside click and on focus out * chore: updated documentation * fix: close button functionality * chore: added test cases for form submission * chore: code cleanup * fix: reset combobox selected state * test(search-input): reorganize suites * test(search-input): correct test cases --------- Co-authored-by: Adam Johnson <[email protected]> Co-authored-by: Benny Powers <[email protected]> Co-authored-by: Benny Powers - עם ישראל חי! <[email protected]>
1 parent d36adee commit bd40a73

19 files changed

+2237
-56
lines changed

.changeset/famous-parts-add.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@patternfly/elements": minor
3+
---
4+
5+
✨ Added `<pf-search-input>`.
6+
7+
A search input consists of a text field where users can type to find specific content or items. Unlike selects or dropdowns, which offer predefined options, a search input lets users enter their own keywords to filter or locate results. It includes a clear (×) button to easily remove the current input, allowing users to start a new search quickly.
8+
9+
Use this when users need to search freely using their own terms — ideal for large or frequently changing sets of content.
10+
Do not use when the options are limited and known ahead of time — consider a dropdown or select instead
11+
12+
```html
13+
<pf-search-input>
14+
<pf-option value="Alabama"> Alabama </pf-option>
15+
<pf-option value="New Jersey"> New Jersey </pf-option>
16+
<pf-option value="New York"> New York </pf-option>
17+
<pf-option value="New Mexico"> New Mexico </pf-option>
18+
<pf-option value="North Carolina"> North Carolina </pf-option>
19+
</pf-search-input>
20+
```

core/pfe-core/controllers/combobox-controller.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { nothing, type ReactiveController, type ReactiveControllerHost } from 'lit';
1+
import { isServer, nothing, type ReactiveController, type ReactiveControllerHost } from 'lit';
22
import type { ActivedescendantControllerOptions } from './activedescendant-controller.js';
33
import type { RovingTabindexControllerOptions } from './roving-tabindex-controller.js';
44
import type { ATFocusController } from './at-focus-controller';
@@ -188,6 +188,10 @@ export class ComboboxController<
188188

189189
private static langsRE = new RegExp(ComboboxController.langs.join('|'));
190190

191+
private static instances = new WeakMap<ReactiveControllerHost, ComboboxController<HTMLElement>>();
192+
193+
private static hosts = new Set<ReactiveControllerHost>();
194+
191195
static {
192196
// apply visually-hidden styles
193197
this.#alertTemplate.innerHTML = `
@@ -205,6 +209,21 @@ export class ComboboxController<
205209
`;
206210
}
207211

212+
// Hide listbox on focusout
213+
static {
214+
if (!isServer) {
215+
document.addEventListener('focusout', event => {
216+
const target = event.target as HTMLElement;
217+
for (const host of ComboboxController.hosts) {
218+
if (host instanceof Node && host.contains(target)) {
219+
const instance = ComboboxController.instances.get(host);
220+
instance?._onFocusoutElement();
221+
}
222+
}
223+
});
224+
}
225+
}
226+
208227
private options: RequireProps<ComboboxControllerOptions<Item>,
209228
| 'isItemDisabled'
210229
| 'isItem'
@@ -333,6 +352,8 @@ export class ComboboxController<
333352
isItemDisabled: this.options.isItemDisabled,
334353
setItemSelected: this.options.setItemSelected,
335354
});
355+
ComboboxController.instances.set(host, this);
356+
ComboboxController.hosts.add(host);
336357
}
337358

338359
async hostConnected(): Promise<void> {
@@ -347,18 +368,31 @@ export class ComboboxController<
347368
const expanded = this.options.isExpanded();
348369
this.#button?.setAttribute('aria-expanded', String(expanded));
349370
this.#input?.setAttribute('aria-expanded', String(expanded));
350-
if (this.#hasTextInput) {
351-
this.#button?.setAttribute('tabindex', '-1');
352-
} else {
353-
this.#button?.removeAttribute('tabindex');
354-
}
355371
this.#initLabels();
356372
}
357373

358374
hostDisconnected(): void {
359375
this.#fc?.hostDisconnected();
360376
}
361377

378+
disconnect(): void {
379+
ComboboxController.instances.delete(this.host);
380+
ComboboxController.hosts.delete(this.host);
381+
}
382+
383+
async _onFocusoutElement(): Promise<void> {
384+
if (this.#hasTextInput && this.options.isExpanded()) {
385+
const root = this.#element?.getRootNode();
386+
await new Promise(requestAnimationFrame);
387+
if (root instanceof ShadowRoot || root instanceof Document) {
388+
const { activeElement } = root;
389+
if (!this.#element?.contains(activeElement)) {
390+
this.#hide();
391+
}
392+
}
393+
}
394+
}
395+
362396
/**
363397
* Order of operations is important
364398
*/

core/pfe-core/controllers/test/combobox-controller.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,15 @@ abstract class TestCombobox extends ReactiveElement {
108108
expect(await a11ySnapshot()).axTreeFocusedNode.to.have.axRole('combobox');
109109
});
110110

111-
describe('Tab', function() {
112-
beforeEach(press('Tab'));
113-
beforeEach(updateComplete);
114-
beforeEach(nextFrame);
115-
116-
it('does not focus the toggle button', async function() {
117-
expect(await a11ySnapshot()).to.not.axContainQuery({ focused: true });
118-
});
119-
});
111+
// describe('Tab', function() {
112+
// beforeEach(press('Tab'));
113+
// beforeEach(updateComplete);
114+
// beforeEach(nextFrame);
115+
116+
// it('does not focus the toggle button', async function() {
117+
// expect(await a11ySnapshot()).to.not.axContainQuery({ focused: true });
118+
// });
119+
// });
120120

121121
describe('ArrowDown', function() {
122122
beforeEach(press('ArrowDown'));

elements/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"./pf-progress-stepper/pf-progress-step.js": "./pf-progress-stepper/pf-progress-step.js",
4747
"./pf-progress-stepper/pf-progress-stepper.js": "./pf-progress-stepper/pf-progress-stepper.js",
4848
"./pf-progress/pf-progress.js": "./pf-progress/pf-progress.js",
49+
"./pf-search-input/pf-search-input.js": "./pf-search-input/pf-search-input.js",
4950
"./pf-spinner/pf-spinner.js": "./pf-spinner/pf-spinner.js",
5051
"./pf-switch/pf-switch.js": "./pf-switch/pf-switch.js",
5152
"./pf-table/context.js": "./pf-table/context.js",

elements/pf-search-input/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Search Input
2+
A search input lets users type in words to find specific items or information. As they type, it can show matching results to help them quickly find what they are looking for.
3+
4+
## Usage
5+
A search input consists of a text field where users can type to find specific content or items. Unlike selects or dropdowns, which offer predefined options, a search input lets users enter their own keywords to filter or locate results. It includes a clear (×) button to easily remove the current input, allowing users to start a new search quickly.
6+
7+
```html
8+
<pf-search-input>
9+
<pf-option value="Alabama"> Alabama </pf-option>
10+
<pf-option value="New Jersey"> New Jersey </pf-option>
11+
<pf-option value="New York"> New York </pf-option>
12+
<pf-option value="New Mexico"> New Mexico </pf-option>
13+
<pf-option value="North Carolina"> North Carolina </pf-option>
14+
</pf-search-input>
15+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<form class="container">
2+
<div class="search-input-container">
3+
<pf-search-input disabled id="search-input" name="search" placeholder="Search">
4+
<pf-option>Blue</pf-option>
5+
<pf-option>Green</pf-option>
6+
<pf-option>Magenta</pf-option>
7+
<pf-option>Orange</pf-option>
8+
<pf-option>Purple</pf-option>
9+
<pf-option>Periwinkle</pf-option>
10+
<pf-option>Pink</pf-option>
11+
<pf-option>Red</pf-option>
12+
<pf-option>Yellow</pf-option>
13+
</pf-search-input>
14+
<pf-button> Search</pf-button>
15+
</div>
16+
</form>
17+
18+
<script type="module">
19+
import '@patternfly/elements/pf-search-input/pf-search-input.js';
20+
</script>
21+
22+
<style>
23+
.container {
24+
padding: 40px;
25+
.search-input-container {
26+
display: flex;
27+
align-items: center;
28+
justify-content: flex-start;
29+
#search-input {
30+
margin-right: 1rem;
31+
}
32+
}
33+
}
34+
</style>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<form class="container">
2+
<div class="search-input-container">
3+
<pf-search-input id="search-input" name="search" placeholder="Search">
4+
<pf-option value="Alabama"> Alabama </pf-option>
5+
<pf-option value="New Jersey"> New Jersey </pf-option>
6+
<pf-option value="New York"> New York </pf-option>
7+
<pf-option value="New Mexico"> New Mexico </pf-option>
8+
<pf-option value="North Carolina"> North Carolina </pf-option>
9+
<pf-option value="Alabama1"> Alabama 1 </pf-option>
10+
<pf-option value="New Jersey1"> New Jersey 1 </pf-option>
11+
<pf-option value="New York1"> New York 1 </pf-option>
12+
<pf-option value="New Mexico1"> New Mexico 1</pf-option>
13+
<pf-option value="North Carolina1"> North Carolina 1</pf-option>
14+
<pf-option value="Alabama2"> Alabama 2 </pf-option>
15+
<pf-option value="New Jersey2"> New Jersey 2 </pf-option>
16+
<pf-option value="New York2"> New York 2 </pf-option>
17+
<pf-option value="New Mexico2"> New Mexico 2 </pf-option>
18+
<pf-option value="North Carolina2"> North Carolina 2 </pf-option>
19+
<pf-option value="Alabama3"> Alabama 3 </pf-option>
20+
<pf-option value="New Jersey3"> New Jersey 3 </pf-option>
21+
<pf-option value="New York3"> New York 3 </pf-option>
22+
<pf-option value="New Mexico3"> New Mexico 3 </pf-option>
23+
<pf-option value="North Carolina3"> North Carolina 3 </pf-option>
24+
</pf-search-input>
25+
<pf-button> Search</pf-button>
26+
</div>
27+
</form>
28+
29+
30+
<script type="module">
31+
import '@patternfly/elements/pf-search-input/pf-search-input.js';
32+
33+
const searchInput = document.getElementById('search-input');
34+
35+
searchInput.addEventListener('change', (event) => {
36+
/* eslint-disable no-console */
37+
console.log('Selected:', event.target.value);
38+
/* eslint-disable no-console */
39+
});
40+
41+
const form = document.querySelector('form.container');
42+
form.addEventListener('submit', (event) =>{
43+
event.preventDefault();
44+
/* eslint-disable no-console */
45+
console.log("Value:", form.elements.search?.value);
46+
/* eslint-disable no-console */
47+
})
48+
</script>
49+
50+
<style>
51+
.container {
52+
padding: 40px;
53+
.search-input-container {
54+
display: flex;
55+
align-items: center;
56+
justify-content: flex-start;
57+
#search-input {
58+
margin-right: 1rem;
59+
}
60+
}
61+
}
62+
</style>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<div class="container">
2+
<pf-search-input id="search-input" placeholder="Search">
3+
<pf-option>What is Red Hat Enterprise Linux?</pf-option>
4+
<pf-option>How does Red Hat OpenShift work?</pf-option>
5+
<pf-option>Why use Red Hat Ansible for automation?</pf-option>
6+
<pf-option>Where can Red Hat OpenShift be deployed?</pf-option>
7+
<pf-option>When should you use Red Hat Enterprise Linux?</pf-option>
8+
<pf-option>What is Red Hat Satellite?</pf-option>
9+
<pf-option>How does Red Hat integrate with AWS and other clouds?</pf-option>
10+
<pf-option>Why choose Red Hat over other Linux vendors?</pf-option>
11+
<pf-option>Where can I learn Red Hat technologies?</pf-option>
12+
<pf-option>When does support end for RHEL versions?</pf-option>
13+
<pf-option>What are Red Hat certifications?</pf-option>
14+
<pf-option>How do you secure a RHEL server?</pf-option>
15+
<pf-option>Why use OpenShift instead of vanilla Kubernetes?</pf-option>
16+
<pf-option>Where is Red Hat headquartered?</pf-option>
17+
<pf-option>When should you use Red Hat CoreOS?</pf-option>
18+
<pf-option>What is Red Hat Insights?</pf-option>
19+
<pf-option>How do you manage Red Hat subscriptions?</pf-option>
20+
<pf-option>Why is RHEL considered enterprise-grade?</pf-option>
21+
<pf-option>Where can I download RHEL for testing?</pf-option>
22+
<pf-option>When was Red Hat founded?</pf-option>
23+
</pf-search-input>
24+
</div>
25+
26+
<script type="module">
27+
import '@patternfly/elements/pf-search-input/pf-search-input.js';
28+
29+
const searchInput = document.getElementById('search-input');
30+
31+
searchInput.addEventListener('change', (event) => {
32+
/* eslint-disable no-console */
33+
console.log('Selected:', event.target.value);
34+
/* eslint-disable no-console */
35+
});
36+
</script>
37+
38+
<style>
39+
.container {
40+
padding: 40px;
41+
}
42+
</style>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
{% renderInstallation %} {% endrenderInstallation %}
2+
3+
<script type="module">
4+
import '@patternfly/elements/pf-search-input/pf-search-input.js';
5+
</script>
6+
7+
{% renderOverview %}
8+
<pf-search-input>
9+
<pf-option>Blue</pf-option>
10+
<pf-option>Black</pf-option>
11+
<pf-option>Brown</pf-option>
12+
<pf-option>Bronze</pf-option>
13+
<pf-option>Green</pf-option>
14+
<pf-option>Magenta</pf-option>
15+
<pf-option>Orange</pf-option>
16+
<pf-option>Purple</pf-option>
17+
<pf-option>Periwinkle</pf-option>
18+
<pf-option>Pink</pf-option>
19+
<pf-option>Red</pf-option>
20+
<pf-option>Yellow</pf-option>
21+
</pf-search-input>
22+
{% endrenderOverview %}
23+
24+
{% band header="Usage" %}
25+
26+
#### Search Input
27+
28+
{% htmlexample %}
29+
{% renderFile "./elements/pf-search-input/demo/pf-search-input.html" %}
30+
{% endhtmlexample %}
31+
32+
#### Search Input Form
33+
{% htmlexample %}
34+
{% renderFile "./elements/pf-search-input/demo/pf-search-input-with-submit.html" %}
35+
{% endhtmlexample %}
36+
37+
#### Disabled
38+
{% htmlexample %}
39+
{% renderFile "./elements/pf-search-input/demo/disabled.html" %}
40+
{% endhtmlexample %}
41+
42+
{% endband %}
43+
44+
{% band header="Accessibility" %}
45+
46+
The search input uses the [Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) recommendations from the WAI ARIA [Authoring Best Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg).
47+
48+
When the dropdown is disabled it follows [WAI ARIA focusability recommendations](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols) for composite widget elements, where dropdown items are still focusable even when the dropdown is disabled.
49+
50+
#### Toggle and typeahead input
51+
52+
When focus is on the toggle, the following keyboard interactions apply:
53+
54+
| Key | Function |
55+
| ---------------------- | -------------------------------------------------------------------------------------- |
56+
| <kbd>Down Arrow</kbd> | Opens the listbox and moves focus to the first listbox item. |
57+
| <kbd>Tab</kbd> | Moves focus to the close button if visible; otherwise, moves to the next focusable element, then closes the listbox.|
58+
| <kbd>Shift + Tab</kbd> | Moves focus out of element onto the previous focusable item and closes listbox. |
59+
60+
#### Listbox options
61+
62+
Listbox options use the [APG's Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) recommendation. When focus is on the listbox, the following keyboard interactions apply:
63+
64+
| Key | Function |
65+
| ---------------------- | ------------------------------------------------------------------------------------- |
66+
| <kbd>Enter</kbd> | Selects the options and closes the listbox. |
67+
| <kbd>Space</kbd> | Selects the options and closes the listbox. |
68+
| <kbd>Tab</kbd> | Moves focus out of element onto the next focusable options and closes listbox. |
69+
| <kbd>Shift + Tab</kbd> | Moves focus to the toggle button and closes listbox. |
70+
| <kbd>Up Arrow</kbd> | Moves focus to the previous option, optionally wrapping from the first to the last. |
71+
| <kbd>Down Arrow</kbd> | Moves focus to the next option, optionally wrapping from the last to the first. |
72+
| <kbd>Left Arrow</kbd> | Returns focus to the combobox without closing the popup and moves the input cursor one character to the left. If the input cursor is on the left-most character, the cursor does not move. |
73+
| <kbd>Right Arrow</kbd> | Returns focus to the combobox without closing the popup and moves the input cursor one character to the right. If the input cursor is on the right-most character, the cursor does not move. |
74+
| <kbd>Escape</kbd> | Close the listbox that contains focus and return focus to the input. |
75+
| <kbd>Any letter</kbd> | Navigates to the next option that starts with the letter. |
76+
77+
{% endband %}
78+
79+
{% renderSlots for="pf-search-input", header="Slots on `pf-search-input`" %}{% endrenderSlots %}
80+
{% renderAttributes for="pf-search-input", header="Attributes on `pf-search-input`" %}{% endrenderAttributes %}
81+
{% renderMethods for="pf-search-input", header="Methods on `pf-search-input`" %}{% endrenderMethods %}
82+
{% renderEvents for="pf-search-input", header="Events on `pf-search-input`" %}{% endrenderEvents %}
83+
{% renderCssCustomProperties for="pf-search-input", header="CSS Custom Properties on `pf-search-input`" %}{% endrenderCssCustomProperties %}
84+
{% renderCssParts for="pf-search-input", header="CSS Parts on `pf-search-input`" %}{% endrenderCssParts %}
85+
86+
{% renderSlots for="pf-option", header="Slots on `pf-option`" %}{% endrenderSlots %}
87+
{% renderAttributes for="pf-option", header="Attributes on `pf-option`" %}{% endrenderAttributes %}
88+
{% renderMethods for="pf-option", header="Methods on `pf-option`" %}{% endrenderMethods %}
89+
{% renderEvents for="pf-option", header="Events on `pf-option`" %}{% endrenderEvents %}
90+
{% renderCssCustomProperties for="pf-option", header="CSS Custom Properties on `pf-option`" %}{% endrenderCssCustomProperties %}
91+
{% renderCssParts for="pf-option", header="CSS Parts on `pf-option`" %}{% endrenderCssParts %}
13.5 KB
Loading

0 commit comments

Comments
 (0)