From eccba0c8cbe0995c393783d2cb7387d953276872 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Thu, 31 Oct 2024 14:52:30 +0200 Subject: [PATCH] lib: Support headers with TypeaheadSelect --- .../cockpit-components-typeahead-select.scss | 4 + .../cockpit-components-typeahead-select.tsx | 44 +++++- pkg/playground/react-demo-typeahead.jsx | 146 ++++++++++++++++++ pkg/playground/react-patterns.html | 5 + pkg/playground/react-patterns.js | 4 + test/reference | 2 +- test/verify/check-lib | 136 ++++++++++++++++ 7 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 pkg/lib/cockpit-components-typeahead-select.scss create mode 100644 pkg/playground/react-demo-typeahead.jsx create mode 100755 test/verify/check-lib diff --git a/pkg/lib/cockpit-components-typeahead-select.scss b/pkg/lib/cockpit-components-typeahead-select.scss new file mode 100644 index 000000000000..64d5c59f9fce --- /dev/null +++ b/pkg/lib/cockpit-components-typeahead-select.scss @@ -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); +} diff --git a/pkg/lib/cockpit-components-typeahead-select.tsx b/pkg/lib/cockpit-components-typeahead-select.tsx index 993a3d3c87b0..29531d822870 100644 --- a/pkg/lib/cockpit-components-typeahead-select.tsx +++ b/pkg/lib/cockpit-components-typeahead-select.tsx @@ -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 */ @@ -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; @@ -95,6 +111,8 @@ export interface TypeaheadSelectOption extends Omit { @@ -137,8 +155,12 @@ export interface TypeaheadSelectProps extends Omit - 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 = ({ innerRef, @@ -175,9 +197,11 @@ export const TypeaheadSelectBase: React.FunctionComponent 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; @@ -320,8 +344,6 @@ export const TypeaheadSelectBase: React.FunctionComponent openMenu(); - const ignore = o => o.isDisabled || o.divider; - if (filteredSelections.every(ignore)) { return; } @@ -461,6 +483,18 @@ export const TypeaheadSelectBase: React.FunctionComponent if (option.divider) return ; + if (option.header) { + const { header, content, ...props } = option; + return ( + + {option.header} + + ); + } + const { content, value, ...props } = option; return ( diff --git a/pkg/playground/react-demo-typeahead.jsx b/pkg/playground/react-demo-typeahead.jsx new file mode 100644 index 000000000000..5e013b11ae61 --- /dev/null +++ b/pkg/playground/react-demo-typeahead.jsx @@ -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 . + */ + +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 ( +
+ 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)} + /> +
Selected: {value || "-"}
+
Toggles: {toggles}
+
Changes: {changes}
+ setIsCreatable(checked)} + /> + setNotFoundIsString(checked)} + /> +
+ ); +}; + +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(); +} diff --git a/pkg/playground/react-patterns.html b/pkg/playground/react-patterns.html index d88a70fe5dfe..378d9ffd3ec5 100644 --- a/pkg/playground/react-patterns.html +++ b/pkg/playground/react-patterns.html @@ -18,6 +18,11 @@

Select file

+
+

Typeahead

+
+
+

Dialogs

diff --git a/pkg/playground/react-patterns.js b/pkg/playground/react-patterns.js index 692352623e5c..7743c12769a7 100644 --- a/pkg/playground/react-patterns.js +++ b/pkg/playground/react-patterns.js @@ -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 @@ -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')); diff --git a/test/reference b/test/reference index 864e70a6bdbd..dd31eae9808a 160000 --- a/test/reference +++ b/test/reference @@ -1 +1 @@ -Subproject commit 864e70a6bdbd560c670c2a5762dd3a30ab0534ac +Subproject commit dd31eae9808abbbc5a9409bddfe9bf69007b6f1a diff --git a/test/verify/check-lib b/test/verify/check-lib new file mode 100755 index 000000000000..9fa192947517 --- /dev/null +++ b/test/verify/check-lib @@ -0,0 +1,136 @@ +#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv) + +# 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 . + +import testlib + + +@testlib.nondestructive +class TestLib(testlib.MachineCase): + + def testTypeaheadSelect(self): + b = self.browser + + # Login + + self.login_and_go("/playground/react-patterns") + + # No clear button + + b.wait_not_visible("#demo-typeahead .pf-v5-c-text-input-group__utilities button") + + # Open menu, pixel test + + b.click("#demo-typeahead .pf-v5-c-menu-toggle__button") + b.assert_pixels("#typeahead-widget", "menu") + b.wait_text("#toggles", "1") + b.wait_text("#changes", "0") + + # Select from menu (with mouse) + + b.click("#typeahead-widget .pf-v5-c-menu__item:contains(Hawaii)") + b.wait_not_present("#typeahead-widget") + b.assert_pixels("#demo-typeahead .pf-v5-c-menu-toggle", "input") + b.wait_text("#value", "HI") + b.wait_text("#toggles", "2") + b.wait_text("#changes", "0") + + # Clear + + b.click("#demo-typeahead .pf-v5-c-text-input-group__utilities button") + b.wait_not_visible("#demo-typeahead .pf-v5-c-text-input-group__utilities button") + b.wait_text("#value", "-") + b.wait_text("#toggles", "2") + b.wait_text("#changes", "1") + + # Open by clicking into input, close with ESC + + b.click("#demo-typeahead .pf-v5-c-text-input-group__text-input") + b.wait_visible("#typeahead-widget") + b.key("Escape") + b.wait_not_present("#typeahead-widget") + b.wait_text("#toggles", "4") + b.wait_text("#changes", "1") + + # Select with keys, verify that dividers and headers are skipped + + b.click("#demo-typeahead .pf-v5-c-menu-toggle__button") + b.wait_visible("#typeahead-widget") + b.key("ArrowDown") + b.key("ArrowUp") # wraps around + b.key("ArrowUp") # skips over divider + b.key("ArrowDown") # skips over divider + b.key("ArrowDown") # wraps around + b.key("ArrowDown") # skips over divider and header + b.key("ArrowDown") + b.key("Enter") + b.wait_not_present("#typeahead-widget") + b.wait_text("#value", "AL") + b.wait_val("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Alabama") + b.wait_text("#toggles", "6") + b.wait_text("#changes", "1") + + b.click("#demo-typeahead .pf-v5-c-text-input-group__utilities button") + b.wait_text("#value", "-") + b.wait_text("#toggles", "6") + b.wait_text("#changes", "2") + + # Search for non-existent + + b.set_input_text("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Olabama") + b.wait_text("#typeahead-widget .pf-v5-c-menu__item", "'Olabama' not found") + b.click("#demo-typeahead .pf-v5-c-menu-toggle__button") + b.wait_not_present("#typeahead-widget") + b.wait_text("#toggles", "8") + b.wait_text("#changes", "9") + + # Again with formatted "not found" message + + b.set_checked("#notFoundIsString", val=True) + b.set_input_text("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Olabama") + b.wait_text("#typeahead-widget .pf-v5-c-menu__item", "Not found") + b.click("#demo-typeahead .pf-v5-c-menu-toggle__button") + b.wait_not_present("#typeahead-widget") + b.wait_text("#toggles", "10") + b.wait_text("#changes", "16") + + # Search for existing, pixel test, select + + b.set_input_text("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Flori") + b.wait_visible("#typeahead-widget") + b.assert_pixels("#typeahead-widget", "search") + b.click("#typeahead-widget .pf-v5-c-menu__item:contains(Florida)") + b.wait_not_present("#typeahead-widget") + b.wait_text("#value", "FL") + b.wait_text("#toggles", "12") + b.wait_text("#changes", "21") + + # Enable creation, create new state + + b.set_checked("#isCreatable", val=True) + b.set_input_text("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Flori") + b.click("#typeahead-widget .pf-v5-c-menu__item:contains(Create Flori)") + b.wait_not_present("#typeahead-widget") + b.wait_text("#value", "Flori") + b.wait_val("#demo-typeahead .pf-v5-c-text-input-group__text-input", "Flori") + b.wait_text("#toggles", "14") + b.wait_text("#changes", "26") + + +if __name__ == '__main__': + testlib.test_main()