Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: Support headers with TypeaheadSelect #21183

Merged
merged 4 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-c-menu__group-title--Color);
font-size: var(--pf-v5-c-menu__group-title--FontSize);
}
108 changes: 88 additions & 20 deletions pkg/lib/cockpit-components-typeahead-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,25 @@ SOFTWARE.

[
...
{ divider: true },
{ decorator: "divider", key: "..." },
...
]

- Allow headers.

[
...
{ decorator: "header", content: _("Nice things"), key: "..." }
{ 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.

*/

/* eslint-disable */
Expand All @@ -83,20 +98,38 @@ import {
SelectProps
} from '@patternfly/react-core';
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
import "cockpit-components-typeahead-select.scss";

const _ = cockpit.gettext;

export interface TypeaheadSelectOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
export interface TypeaheadSelectDividerOption {
decorator: "divider";
Comment on lines +105 to +106
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really cool!


key: string | number;
};

export interface TypeaheadSelectHeaderOption {
decorator: "header";

content: string | number;
key: string | number;
};

export interface TypeaheadSelectMenuOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
decorator?: undefined;

/** Content of the select option. */
content: string | number;
/** Value of the select option. */
value: string | number;
/** Indicator for option being selected */
isSelected?: boolean;
/** Is this just a divider */
divider?: boolean;
}

export type TypeaheadSelectOption = TypeaheadSelectMenuOption |
TypeaheadSelectDividerOption |
TypeaheadSelectHeaderOption;

export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
/** @hide Forwarded ref */
innerRef?: React.Ref<any>;
Expand Down Expand Up @@ -137,8 +170,27 @@ export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSe
toggleProps?: MenuToggleProps;
}

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, keep headers and dividers
const filtered = options.filter((o) => {
return o.decorator || String(o.content).toLowerCase().includes(filterValue.toLowerCase());
});

// Remove headers that have nothing following them, and dividers that have nothing in front of them.
const filtered2 = filtered.filter((o, i) => {
if (o.decorator == "header" && (i >= filtered.length - 1 || filtered[i + 1].decorator))
return false;
if (o.decorator == "divider" && (i <= 0 || filtered[i - 1].decorator))
return false;
return true;
});

// If the last item is now a divider, remove it as well.
if (filtered2.length > 0 && filtered2[filtered2.length-1].decorator == "divider")
filtered2.pop();

return filtered2;
};

export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
innerRef,
Expand All @@ -149,7 +201,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
onInputChange,
filterFunction = defaultFilterFunction,
onClearSelection,
placeholder = 'Select an option',
placeholder = _("Select an option"),
noOptionsAvailableMessage = _("No results found"),
noOptionsFoundMessage = _("No results found"),
isCreatable = false,
Expand All @@ -175,9 +227,14 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
if (isCreatable)
selectedIsTrusted = true;

const isMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !o.decorator;
const isEnabledMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !(o.decorator || o.isDisabled);

const selected = React.useMemo(
() => {
let res = selectOptions?.find((option) => option.value === props.selected || option.isSelected);
let res = selectOptions?.find((o): o is TypeaheadSelectMenuOption =>
(isEnabledMenu(o) &&
(o.value === props.selected || !!o.isSelected)));
if (!res && props.selected && selectedIsTrusted)
res = { value: props.selected, content: props.selected };
return res;
Expand All @@ -195,7 +252,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
if (
isCreatable &&
filterValue.trim() &&
!newSelectOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase())
!newSelectOptions.find((o) => isMenu(o) && String(o.content).toLowerCase() === filterValue.toLowerCase())
) {
const createOption = {
content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue),
Expand Down Expand Up @@ -253,7 +310,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

const setActiveAndFocusedItem = (itemIndex: number) => {
setFocusedItemIndex(itemIndex);
const focusedItem = selectOptions[itemIndex];
const focusedItem = selectOptions[itemIndex] as TypeaheadSelectMenuOption;
setActiveItemId(String(focusedItem.value));
};

Expand Down Expand Up @@ -290,15 +347,16 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

const selectOption = (
_event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
option: TypeaheadSelectOption
option: TypeaheadSelectMenuOption
) => {
onSelect && onSelect(_event, option.value);
closeMenu();
};

const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
if (value && value !== NO_RESULTS) {
const optionToSelect = selectOptions.find((option) => option.value === value);
const optionToSelect = selectOptions.find(
(option): option is TypeaheadSelectMenuOption => isMenu(option) && option.value === value);
if (optionToSelect) {
selectOption(_event, optionToSelect);
} else if (isCreatable) {
Expand All @@ -320,7 +378,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

openMenu();

if (filteredSelections.every((option) => option.isDisabled)) {
if (filteredSelections.every(o => !isMenu(o))) {
return;
}

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

// Skip disabled options
while (filteredSelections[indexToFocus].isDisabled) {
// Skip non-items
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus--;
if (indexToFocus === -1) {
indexToFocus = filteredSelections.length - 1;
Expand All @@ -349,8 +407,8 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
indexToFocus = focusedItemIndex + 1;
}

// Skip disabled options
while (filteredSelections[indexToFocus].isDisabled) {
// Skip non-items
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus++;
if (indexToFocus === filteredSelections.length) {
indexToFocus = 0;
Expand All @@ -362,7 +420,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
};

const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const focusedItem = focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null;
const focusedItem = (focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null) as TypeaheadSelectMenuOption | null;

switch (event.key) {
case 'Enter':
Expand Down Expand Up @@ -456,8 +514,18 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
>
<SelectList>
{filteredSelections.map((option, index) => {
if (option.divider)
return <Divider key={option.key || index} component="li" />;
if (option.decorator == "divider")
return <Divider key={option.key} component="li" />;

if (option.decorator == "header") {
return (
<SelectOption key={option.key}
isDisabled
className="ct-typeahead-header">
{option.content}
</SelectOption>
);
}

const { content, value, ...props } = option;
return (
Expand Down
147 changes: 147 additions & 0 deletions pkg/playground/react-demo-typeahead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2024 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 cockpit from "cockpit";

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

import { Checkbox } from '@patternfly/react-core';
import { TypeaheadSelect, TypeaheadSelectOption } from "cockpit-components-typeahead-select";

const TypeaheadDemo = ({ options } : { options: TypeaheadSelectOption[] }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really amazed that you could fully type this without too much effort. Makes the "type ahead demo" twice as interesting! 😉

const [isCreatable, setIsCreatable] = useState(false);
const [notFoundIsString, setNotFoundIsString] = useState(false);
const [value, setValue] = useState<string | number | null>();
const [toggles, setToggles] = useState(0);
const [changes, setChanges] = useState(0);

return (
<div>
<TypeaheadSelect
id='typeahead-widget'
placeholder="Select a state"
isScrollable
noOptionsFoundMessage={notFoundIsString ? "Not found" : val => cockpit.format("'$0' not found", val) }
isCreatable={isCreatable}
createOptionMessage={val => cockpit.format("Create $0", val)}
onClearSelection={() => setValue(null)}
selectOptions={options}
selected={value}
onSelect={(_, value) => setValue(value) }
onToggle={() => setToggles(val => val + 1)}
onInputChange={() => setChanges(val => val + 1)}
/>
<div>Selected: <span id="value">{value || "-"}</span></div>
<div>Toggles: <span id="toggles">{toggles}</span></div>
<div>Changes: <span id="changes">{changes}</span></div>
<Checkbox
id="isCreatable"
label="isCreatable"
isChecked={isCreatable}
onChange={(_event, checked) => setIsCreatable(checked)}
/>
<Checkbox
id="notFoundIsString"
label="notFoundIsString"
isChecked={notFoundIsString}
onChange={(_event, checked) => setNotFoundIsString(checked)}
/>
</div>
);
};

export function showTypeaheadDemo(rootElement: Container) {
const states: Record<string, string> = {
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: TypeaheadSelectOption[] = [];
let last = "";

options.push({ value: "start", content: "The Start" });

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

options.push({ decorator: "divider", key: "_divider-end" });
options.push({ value: "end", content: "The End" });

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
Loading