Skip to content

Commit

Permalink
lib: Support headers with TypeaheadSelect
Browse files Browse the repository at this point in the history
  • Loading branch information
mvollmer committed Oct 29, 2024
1 parent 71bca23 commit 5f5a440
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 15 deletions.
4 changes: 4 additions & 0 deletions pkg/lib/cockpit-components-typeahead-select.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.ct-typeahead-header .pf-v5-c-menu__item-main {
color: var(--pf-v5-global--primary-color--100);
font-size: var(--pf-v5-global--FontSize--sm);
}
55 changes: 40 additions & 15 deletions pkg/lib/cockpit-components-typeahead-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,20 @@ SOFTWARE.
...
]
- When not actively filtering, the "selected" value will be shown
as the current value in the text input, regardless of whether it
actually is in the list of options or not.
- Allow headers.
This is quite obviously the right thing to do when "isCreatable"
is set. In that case the selected value is never restricted to
the values in the list. But it is also helpful without
"isCreatable" when the list of options is produced asynchronously
such as with FileAutoComplete, or might otherwise be out-of-date.
[
...
{ header: _("Nice things") }
{ value: "icecream", content: _("Icecream") },
...
]
Note that PatternFly uses SelectGroup and MenuGroup instead of
headers, but their recursive nature makes them harder to
implement here, mostly because of how keyboard navigation is
done. And there is no visual nesting going on anyway. Keeping the
options a flat list is just all around easier.
*/

Expand All @@ -97,6 +102,8 @@ import {
} from '@patternfly/react-core';
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';

import "cockpit-components-typeahead-select.scss";

export interface TypeaheadSelectOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
/** Content of the select option. */
content: string | number;
Expand All @@ -106,6 +113,8 @@ export interface TypeaheadSelectOption extends Omit<SelectOptionProps, 'content'
isSelected?: boolean;
/** Is this just a divider */
divider?: boolean;
/** Is this a header? */
header?: string;
}

export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect' | 'isScrollable'> {
Expand Down Expand Up @@ -149,8 +158,12 @@ export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSe
const defaultNoOptionsFoundMessage = (filter: string) => `No results found for "${filter}"`;
const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`;

const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) =>
options.filter((o) => String(o.content).toLowerCase().includes(filterValue.toLowerCase()));
const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => {
// Filter by search term
const filtered = options.filter((o) => o.header || String(o.content).toLowerCase().includes(filterValue.toLowerCase()))
// Remove headers that have nothing following them.
return filtered.filter((o, i) => !(o.header && (i >= filtered.length - 1 || filtered[i + 1].header)));
};

export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
innerRef,
Expand Down Expand Up @@ -325,7 +338,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

openMenu();

if (filteredSelections.every((option) => option.isDisabled)) {
if (filteredSelections.every((option) => (option.isDisabled || option.isHeader))) {
return;
}

Expand All @@ -337,8 +350,8 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
indexToFocus = focusedItemIndex - 1;
}

// Skip disabled options
while (filteredSelections[indexToFocus].isDisabled) {
// Skip disabled options and headers
while (filteredSelections[indexToFocus].isDisabled || filteredSelections[indexToFocus].header) {
indexToFocus--;
if (indexToFocus === -1) {
indexToFocus = filteredSelections.length - 1;
Expand All @@ -354,8 +367,8 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
indexToFocus = focusedItemIndex + 1;
}

// Skip disabled options
while (filteredSelections[indexToFocus].isDisabled) {
// Skip disabled options and headers
while (filteredSelections[indexToFocus].isDisabled || filteredSelections[indexToFocus].header) {
indexToFocus++;
if (indexToFocus === filteredSelections.length) {
indexToFocus = 0;
Expand Down Expand Up @@ -467,6 +480,18 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
if (option.divider)
return <Divider key={index.toString()} component="li" />;

if (option.header) {
const { header, ...props } = option;
return (
<SelectOption key={index.toString()}
isDisabled
className="ct-typeahead-header"
{...props}>
{option.header}
</SelectOption>
);
}

const { content, value, ...props } = option;
return (
<SelectOption key={index.toString()}
Expand Down
109 changes: 109 additions & 0 deletions pkg/playground/react-demo-typeahead.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2017 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
*/

import React, { useState } from "react";
import { createRoot } from 'react-dom/client';

import { TypeaheadSelect } from "cockpit-components-typeahead-select";

const TypeaheadDemo = ({ options }) => {
const [value, setValue ] = useState(null);

return (
<div>
<TypeaheadSelect id='typeahead-widget'
selectOptions={options}
selected={value}
onSelect={(_, value) => {
setValue(value);
}}
/>
<span>Selected: {value || "-"}</span>
</div>
);
};

export function showTypeaheadDemo(rootElement) {
const states = {
"AL": "Alabama",
"AK": "Alaska",
"AZ": "Arizona",
"AR": "Arkansas",
"CA": "California",
"CO": "Colorado",
"CT": "Connecticut",
"DE": "Delaware",
"FL": "Florida",
"GA": "Georgia",
"HI": "Hawaii",
"ID": "Idaho",
"IL": "Illinois",
"IN": "Indiana",
"IA": "Iowa",
"KS": "Kansas",
"KY": "Kentucky",
"LA": "Louisiana",
"ME": "Maine",
"MD": "Maryland",
"MA": "Massachusetts",
"MI": "Michigan",
"MN": "Minnesota",
"MS": "Mississippi",
"MO": "Missouri",
"MT": "Montana",
"NE": "Nebraska",
"NV": "Nevada",
"NH": "New Hampshire",
"NJ": "New Jersey",
"NM": "New Mexico",
"NY": "New York",
"NC": "North Carolina",
"ND": "North Dakota",
"OH": "Ohio",
"OK": "Oklahoma",
"OR": "Oregon",
"PA": "Pennsylvania",
"RI": "Rhode Island",
"SC": "South Carolina",
"SD": "South Dakota",
"TN": "Tennessee",
"TX": "Texas",
"UT": "Utah",
"VT": "Vermont",
"VA": "Virginia",
"WA": "Washington",
"WV": "West Virginia",
"WI": "Wisconsin",
"WY": "Wyoming"
};

const options = [];
let last = "";

for (const st of Object.keys(states).sort()) {
if (st[0] != last) {
options.push({ key: "_header-" + st[0], header: "Starting with " + st[0]});
last = st[0];
}
options.push({ value: st, content: states[st] });
}

const root = createRoot(rootElement);
root.render(<TypeaheadDemo options={options} />);
}
5 changes: 5 additions & 0 deletions pkg/playground/react-patterns.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ <h3>Select file</h3>
<div id="demo-file-ac-preselected"></div>
</section>

<section class="pf-v5-c-page__main-section pf-m-light">
<h3>Typeahead</h3>
<div id="demo-typeahead"></div>
</section>

<section class="pf-v5-c-page__main-section pf-m-light">
<h3>Dialogs</h3>
<button id="demo-show-dialog" class="pf-v5-c-button pf-m-secondary">Show Dialog</button>
Expand Down
4 changes: 4 additions & 0 deletions pkg/playground/react-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { PatternDialogBody } from "./react-demo-dialog.jsx";
import { showCardsDemo } from "./react-demo-cards.jsx";
import { showUploadDemo } from "./react-demo-file-upload.jsx";
import { showFileAcDemo, showFileAcDemoPreselected } from "./react-demo-file-autocomplete.jsx";
import { showTypeaheadDemo } from "./react-demo-typeahead.jsx";

/* -----------------------------------------------------------------------------
Modal Dialog
Expand Down Expand Up @@ -125,6 +126,9 @@ document.addEventListener("DOMContentLoaded", function() {
showFileAcDemo(document.getElementById('demo-file-ac'));
showFileAcDemoPreselected(document.getElementById('demo-file-ac-preselected'));

// Plain typeahead select with headers and dividers
showTypeaheadDemo(document.getElementById('demo-typeahead'));

// Cards
showCardsDemo(document.getElementById('demo-cards'));

Expand Down

0 comments on commit 5f5a440

Please sign in to comment.