Skip to content

Commit

Permalink
lib: Support headers with TypeaheadSelect
Browse files Browse the repository at this point in the history
This also changes the way dividers are encoded, so that TypeScript can
better understand the option variants.
  • Loading branch information
mvollmer committed Nov 26, 2024
1 parent 72ed4ee commit 8d9f97b
Show file tree
Hide file tree
Showing 8 changed files with 383 additions and 21 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-c-menu__group-title--Color);
font-size: var(--pf-v5-c-menu__group-title--FontSize);
}
104 changes: 85 additions & 19 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";

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 Down Expand Up @@ -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,9 +378,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

openMenu();

const ignore = o => o.isDisabled || o.divider;

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

Expand All @@ -335,7 +391,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
}

// Skip non-items
while (ignore(filteredSelections[indexToFocus])) {
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus--;
if (indexToFocus === -1) {
indexToFocus = filteredSelections.length - 1;
Expand All @@ -352,7 +408,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
}

// Skip non-items
while (ignore(filteredSelections[indexToFocus])) {
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus++;
if (indexToFocus === filteredSelections.length) {
indexToFocus = 0;
Expand All @@ -364,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 @@ -458,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[] }) => {
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
4 changes: 4 additions & 0 deletions pkg/playground/react-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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 @@ -126,6 +127,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
Loading

0 comments on commit 8d9f97b

Please sign in to comment.