diff --git a/src/components/select/CustomSelect.tsx b/src/components/select/CustomSelect.tsx deleted file mode 100644 index 9431849666..0000000000 --- a/src/components/select/CustomSelect.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import classNames from "classnames"; -import { useEffect, useId, useImperativeHandle, useState } from "react"; -import type { FC, MutableRefObject, ReactNode } from "react"; -import { - ClassName, - Field, - ContextualMenu, - PropsWithSpread, - FieldProps, - Position, -} from "@canonical/react-components"; -import CustomSelectDropdown, { - CustomSelectOption, - getOptionText, -} from "./CustomSelectDropdown"; -import useEventListener from "util/useEventListener"; - -export type SelectRef = MutableRefObject< - | { - open: () => void; - close: () => void; - isOpen: boolean; - focus: () => void; - } - | undefined ->; - -export type Props = PropsWithSpread< - FieldProps, - { - // Selected option value - value: string; - // Array of options that the select can choose from. - options: CustomSelectOption[]; - // Function to run when select value changes. - onChange: (value: string) => void; - // id for the select component - id?: string | null; - // Name for the select element - name?: string; - // Whether if the select is disabled - disabled?: boolean; - // Styling for the wrapping Field component - wrapperClassName?: ClassName; - // The styling for the select toggle button - toggleClassName?: ClassName; - // The styling for the select dropdown - dropdownClassName?: string; - // Whether the select is searchable. Option "auto" is the default, the select will be searchable if it has 5 or more options. - searchable?: "auto" | "always" | "never"; - // Whether to focus on the element on initial render. - takeFocus?: boolean; - // Additional component to display above the dropdwon list. - header?: ReactNode; - // Ref for the select component which exposes internal methods and state for programatic control at the parent level. - selectRef?: SelectRef; - // initial position of the dropdown - initialPosition?: Position; - } ->; - -const CustomSelect: FC = ({ - value, - options, - onChange, - id, - name, - disabled, - success, - error, - help, - wrapperClassName, - toggleClassName, - dropdownClassName, - searchable = "auto", - takeFocus, - header, - selectRef, - initialPosition = "left", - ...fieldProps -}) => { - const [isOpen, setIsOpen] = useState(false); - const validationId = useId(); - const defaultSelectId = useId(); - const selectId = id || defaultSelectId; - const helpId = useId(); - const hasError = !!error; - - // Close the dropdown when the browser tab is hidden - useEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") { - setIsOpen(false); - } - }); - - // Close the dropdown when the browser window loses focus - useEventListener( - "blur", - () => { - setIsOpen(false); - }, - window, - ); - - useImperativeHandle( - selectRef, - () => ({ - open: () => { - setIsOpen(true); - document.getElementById(selectId)?.focus(); - }, - focus: () => document.getElementById(selectId)?.focus(), - close: setIsOpen.bind(null, false), - isOpen: isOpen, - }), - [isOpen], - ); - - useEffect(() => { - if (takeFocus) { - const toggleButton = document.getElementById(selectId); - toggleButton?.focus(); - } - }, [takeFocus]); - - const selectedOption = options.find((option) => option.value === value); - - const toggleLabel = ( - - {selectedOption ? getOptionText(selectedOption) : "Select an option"} - - ); - - const handleSelect = (value: string) => { - document.getElementById(selectId)?.focus(); - setIsOpen(false); - onChange(value); - }; - - return ( - - { - // Handle syncing the state when toggling the menu from within the - // contextual menu component e.g. when clicking outside. - if (open !== isOpen) { - setIsOpen(open); - } - }} - toggleProps={{ - id: selectId, - disabled: disabled, - // tabIndex is set to -1 when disabled to prevent keyboard navigation to the select toggle - tabIndex: disabled ? -1 : 0, - }} - className="p-custom-select__wrapper" - dropdownClassName={dropdownClassName} - // This is unfortunately necessary to prevent the same styling applied to the toggle wrapper as well as the dropdown wrapper - // TODO: should create an upstream fix so that contextualMenuClassname is not applied to both the toggle and dropdown wrappers - style={{ width: "100%" }} - autoAdjust - position={initialPosition} - > - {(close: () => void) => ( - { - // When pressing ESC to close the dropdown, we keep focus on the toggle button - close(); - document.getElementById(selectId)?.focus(); - }} - header={header} - toggleId={selectId} - /> - )} - - - ); -}; - -export default CustomSelect; diff --git a/src/components/select/CustomSelectDropdown.tsx b/src/components/select/CustomSelectDropdown.tsx deleted file mode 100644 index f0662accda..0000000000 --- a/src/components/select/CustomSelectDropdown.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { SearchBox } from "@canonical/react-components"; -import { - FC, - KeyboardEvent, - LiHTMLAttributes, - ReactNode, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import classnames from "classnames"; -import { adjustDropdownHeight } from "util/customSelect"; -import useEventListener from "util/useEventListener"; -import { getNearestParentsZIndex } from "util/zIndex"; - -export type CustomSelectOption = LiHTMLAttributes & { - value: string; - label: ReactNode; - // text used for search, sort and display in toggle button - // text must be provided if label is not a string - text?: string; - disabled?: boolean; -}; - -interface Props { - searchable?: "auto" | "always" | "never"; - name: string; - options: CustomSelectOption[]; - onSelect: (value: string) => void; - onClose: () => void; - header?: ReactNode; - toggleId: string; -} - -export const getOptionText = (option: CustomSelectOption): string => { - if (option.text) { - return option.text; - } - - if (typeof option.label === "string") { - return option.label; - } - - throw new Error( - "CustomSelect: options must have a string label or a text property", - ); -}; - -export const sortOptions = ( - a: CustomSelectOption, - b: CustomSelectOption, -): number => { - // sort options alphabetically - const textA = getOptionText(a) || a.value; - const textB = getOptionText(b) || b.value; - return textA.localeCompare(textB); -}; - -const CustomSelectDropdown: FC = ({ - searchable, - name, - options, - onSelect, - onClose, - header, - toggleId, -}) => { - const [search, setSearch] = useState(""); - // track selected option index for keyboard actions - const [selectedIndex, setSelectedIndex] = useState(0); - // use ref to keep a reference to all option HTML elements so we do not need to make DOM calls later for scrolling - const optionsRef = useRef([]); - const dropdownRef = useRef(null); - const searchRef = useRef(null); - const dropdownListRef = useRef(null); - const isSearchable = - searchable !== "never" && - options.length > 1 && - (searchable === "always" || (searchable === "auto" && options.length >= 5)); - - useEffect(() => { - if (dropdownRef.current) { - const toggle = document.getElementById(toggleId); - - // align width with wrapper toggle width - const toggleWidth = toggle?.getBoundingClientRect()?.width ?? 0; - dropdownRef.current.style.setProperty("min-width", `${toggleWidth}px`); - - // align z-index: when we are in a modal context, we want the dropdown to be above the modal - // apply the nearest parents z-index + 1 - const zIndex = getNearestParentsZIndex(toggle); - if (parseInt(zIndex) > 0) { - dropdownRef.current.parentElement?.style.setProperty( - "z-index", - zIndex + 1, - ); - } - } - - setTimeout(() => { - if (isSearchable) { - searchRef.current?.focus(); - return; - } - - dropdownRef.current?.focus(); - }, 100); - }, [isSearchable]); - - const handleResize = () => { - adjustDropdownHeight(dropdownListRef.current, searchRef.current); - }; - - useLayoutEffect(handleResize, []); - useEventListener("resize", handleResize); - - // track selected index from key board action and scroll into view if needed - useEffect(() => { - optionsRef.current[selectedIndex]?.scrollIntoView({ - block: "nearest", - inline: "nearest", - }); - }, [selectedIndex]); - - const filteredOptions = options?.filter((option) => { - if (!search || option.disabled) return true; - const searchText = getOptionText(option) || option.value; - return searchText.toLowerCase().includes(search); - }); - - const getNextOptionIndex = (goingUp: boolean, prevIndex: number) => { - const increment = goingUp ? -1 : 1; - let currIndex = prevIndex + increment; - // skip disabled options for key board action - while (filteredOptions[currIndex] && filteredOptions[currIndex]?.disabled) { - currIndex += increment; - } - - // consider upper bound for navigating down the list - if (increment > 0) { - return currIndex < filteredOptions.length ? currIndex : prevIndex; - } - - // consider lower bound for navigating up the list - return currIndex >= 0 ? currIndex : prevIndex; - }; - - // handle keyboard actions for navigating the select dropdown - const handleKeyDown = (event: KeyboardEvent) => { - const upDownKeys = ["ArrowUp", "ArrowDown"]; - - // prevent default browser actions for up, down, enter and escape keys - // also prevent any other event listeners from being called up the DOM tree - if ([...upDownKeys, "Enter", "Escape", "Tab"].includes(event.key)) { - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation(); - } - - if (upDownKeys.includes(event.key)) { - setSelectedIndex((prevIndex) => { - const goingUp = event.key === "ArrowUp"; - return getNextOptionIndex(goingUp, prevIndex); - }); - } - - if (event.key === "Enter" && filteredOptions[selectedIndex]) { - onSelect(filteredOptions[selectedIndex].value); - } - - if (event.key === "Escape" || event.key === "Tab") { - onClose(); - } - }; - - const handleSearch = (value: string) => { - setSearch(value.toLowerCase()); - // reset selected index when search text changes - setSelectedIndex(0); - optionsRef.current = []; - }; - - const handleSelect = (option: CustomSelectOption) => { - if (option.disabled) { - return; - } - - onSelect(option.value); - }; - - const optionItems = filteredOptions.map((option, idx) => { - return ( -
  • handleSelect(option)} - className={classnames( - "p-list__item", - "p-custom-select__option", - "u-truncate", - { - disabled: option.disabled, - highlight: idx === selectedIndex && !option.disabled, - }, - )} - // adding option elements to a ref array makes it easier to scroll the element later - // else we'd have to make a DOM call to find the element based on some identifier - ref={(el) => { - if (!el) return; - optionsRef.current[idx] = el; - }} - role="option" - onMouseMove={() => setSelectedIndex(idx)} - > - - {option.label} - -
  • - ); - }); - - return ( -
    { - // when custom select is used in a modal, which is a portal, a dropdown click - // should not close the modal itself, so we stop the event right here. - e.stopPropagation(); - }} - > - {isSearchable && ( -
    - -
    - )} - {header} -
      - {optionItems} -
    -
    - ); -}; - -export default CustomSelectDropdown; diff --git a/src/pages/permissions/panels/PermissionSelector.tsx b/src/pages/permissions/panels/PermissionSelector.tsx index f593a83c69..385884721d 100644 --- a/src/pages/permissions/panels/PermissionSelector.tsx +++ b/src/pages/permissions/panels/PermissionSelector.tsx @@ -1,10 +1,9 @@ -import { Button, useNotify } from "@canonical/react-components"; -import CustomSelect, { SelectRef } from "components/select/CustomSelect"; +import { Button, CustomSelect, useNotify } from "@canonical/react-components"; import { useQuery } from "@tanstack/react-query"; import { fetchPermissions } from "api/auth-permissions"; import { fetchConfigOptions } from "api/server"; import { useSupportedFeatures } from "context/useSupportedFeatures"; -import { FC, useEffect, useRef, useState } from "react"; +import { FC, MutableRefObject, useEffect, useRef, useState } from "react"; import { generateEntitlementOptions, generateResourceOptions, @@ -26,6 +25,16 @@ interface Props { onAddPermission: (permission: FormPermission) => void; } +export type SelectRef = MutableRefObject< + | { + open: () => void; + close: () => void; + isOpen: boolean; + focus: () => void; + } + | undefined +>; + const PermissionSelector: FC = ({ onAddPermission }) => { const notify = useNotify(); const [resourceType, setResourceType] = useState(""); diff --git a/src/pages/storage/StoragePoolSelector.tsx b/src/pages/storage/StoragePoolSelector.tsx index 2a138db7f3..c4848862ca 100644 --- a/src/pages/storage/StoragePoolSelector.tsx +++ b/src/pages/storage/StoragePoolSelector.tsx @@ -1,5 +1,9 @@ import { FC, useEffect } from "react"; -import { useNotify } from "@canonical/react-components"; +import { + CustomSelect, + CustomSelectOption, + useNotify, +} from "@canonical/react-components"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { fetchStoragePools } from "api/storage-pools"; @@ -7,8 +11,6 @@ import Loader from "components/Loader"; import { Props as SelectProps } from "@canonical/react-components/dist/components/Select/Select"; import { useSettings } from "context/useSettings"; import { getSupportedStorageDrivers } from "util/storageOptions"; -import CustomSelect from "components/select/CustomSelect"; -import { CustomSelectOption } from "components/select/CustomSelectDropdown"; import StoragePoolOptionLabel from "./StoragePoolOptionLabel"; import StoragePoolOptionHeader from "./StoragePoolOptionHeader"; diff --git a/src/sass/_custom_select.scss b/src/sass/_custom_select.scss deleted file mode 100644 index 3254e1d4d9..0000000000 --- a/src/sass/_custom_select.scss +++ /dev/null @@ -1,77 +0,0 @@ -.p-custom-select { - // style copied directly from vanilla-framework for the select element - .p-custom-select__toggle { - @include vf-icon-chevron-themed; - @extend %vf-input-elements; - - // stylelint-disable property-no-vendor-prefix - -moz-appearance: none; - -webkit-appearance: none; - appearance: none; - // stylelint-enable property-no-vendor-prefix - background-position: right calc(map-get($grid-margin-widths, default) / 2) - center; - background-repeat: no-repeat; - background-size: map-get($icon-sizes, default); - border-top: none; - box-shadow: none; - min-height: map-get($line-heights, default-text); - padding-right: calc($default-icon-size + 2 * $sph--small); - text-indent: 0.01px; - - &:hover { - cursor: pointer; - } - - // this emulates the highlight effect when the select is focused - // without crowding the content with a border - &.active, - &:focus { - box-shadow: inset 0 0 0 3px $color-focus; - } - - .toggle-label { - display: flow-root; - text-align: left; - width: 100%; - } - } -} - -.p-custom-select__dropdown { - background-color: $colors--theme--background-alt; - box-shadow: $box-shadow--deep; - outline: none; - position: relative; - - .p-custom-select__option { - background-color: $colors--theme--background-alt; - font-weight: $font-weight-regular-text; - padding: $sph--x-small $sph--small; - - &.highlight { - // browser default styling for options when hovered - background-color: #06c; - cursor: pointer; - - // make sure that if an option is highlighted, its text is white for good contrast - * { - color: white; - } - } - } - - .p-custom-select__search { - background-color: $colors--theme--background-alt; - padding: $sph--x-small; - padding-bottom: $sph--small; - position: sticky; - top: 0; - } - - .p-list { - max-height: 30rem; - overflow: auto; - width: 20rem; - } -} diff --git a/src/sass/styles.scss b/src/sass/styles.scss index e1c9289736..02d1202250 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -74,7 +74,6 @@ $border-thin: 1px solid $color-mid-light !default; @import "cluster_list"; @import "configuration_table"; @import "custom_isos"; -@import "custom_select"; @import "detail_page"; @import "detail_panels"; @import "disk_device_form"; diff --git a/src/util/permissions.tsx b/src/util/permissions.tsx index f353aeb6ba..ae3860e194 100644 --- a/src/util/permissions.tsx +++ b/src/util/permissions.tsx @@ -8,12 +8,10 @@ import { import { LxdMetadata } from "types/config"; import { capitalizeFirstLetter } from "./helpers"; import { FormPermission } from "pages/permissions/panels/EditGroupPermissionsForm"; -import { - CustomSelectOption, - sortOptions, -} from "components/select/CustomSelectDropdown"; import ResourceOptionLabel from "pages/permissions/panels/ResourceOptionLabel"; import EntitlementOptionLabel from "pages/permissions/panels/EntitlementOptionLabel"; +import { CustomSelectOption } from "@canonical/react-components"; +import { getOptionText } from "@canonical/react-components/dist/components/CustomSelect/CustomSelectDropdown"; export const noneAvailableOption = { disabled: true, @@ -86,6 +84,16 @@ export const resourceTypeOptions = [ }, ]; +export const sortOptions = ( + a: CustomSelectOption, + b: CustomSelectOption, +): number => { + // sort options alphabetically + const textA = getOptionText(a) || a.value; + const textB = getOptionText(b) || b.value; + return textA.localeCompare(textB); +}; + export const getResourceTypeOptions = ( metadata?: LxdMetadata | null, ): CustomSelectOption[] => {