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 Nov 13, 2024
1 parent 63321dd commit eccba0c
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 6 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);
}
44 changes: 39 additions & 5 deletions pkg/lib/cockpit-components-typeahead-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ SOFTWARE.
...
]
- Allow headers.
[
...
{ 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.
*/

/* eslint-disable */
Expand All @@ -83,6 +98,7 @@ 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;

Expand All @@ -95,6 +111,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'> {
Expand Down Expand Up @@ -137,8 +155,12 @@ 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
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 @@ -175,9 +197,11 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
if (isCreatable)
selectedIsTrusted = true;

const ignore = (o: TypeaheadSelectOption) => o.isDisabled || o.divider || o.header;

const selected = React.useMemo(
() => {
let res = selectOptions?.find((option) => option.value === props.selected || option.isSelected);
let res = selectOptions?.find((o) => !ignore(o) && (o.value === props.selected || o.isSelected));
if (!res && props.selected && selectedIsTrusted)
res = { value: props.selected, content: props.selected };
return res;
Expand Down Expand Up @@ -320,8 +344,6 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>

openMenu();

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

if (filteredSelections.every(ignore)) {
return;
}
Expand Down Expand Up @@ -461,6 +483,18 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
if (option.divider)
return <Divider key={option.key || index} component="li" />;

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

const { content, value, ...props } = option;
return (
<SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
Expand Down
146 changes: 146 additions & 0 deletions pkg/playground/react-demo-typeahead.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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 cockpit from "cockpit";

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

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

const TypeaheadDemo = ({ options }) => {
const [isCreatable, setIsCreatable] = useState(false);
const [notFoundIsString, setNotFoundIsString] = useState(false);
const [value, setValue] = useState();
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) {
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 = "";

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

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

options.push({ divider: true });
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 @@ -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
Loading

0 comments on commit eccba0c

Please sign in to comment.