diff --git a/src/components/account-filter.cy.tsx b/src/components/account-filter.cy.tsx index a2ecacca..d7d84bc3 100644 --- a/src/components/account-filter.cy.tsx +++ b/src/components/account-filter.cy.tsx @@ -43,32 +43,63 @@ describe("", () => { cy.mount(); /** Default value is properly set to first option */ - cy.get("select").should("have.value", accountsSorted[0].account_sid); + cy.get("input").should("have.value", accountsSorted[0].name); }); it("updates value onChange", () => { cy.mount(); /** Assert onChange value updates */ - cy.get("select").select(accountsSorted[1].account_sid); - cy.get("select").should("have.value", accountsSorted[1].account_sid); + cy.get("input").clear(); + cy.get("input").type(accountsSorted[1].name); + cy.get("input").should("have.value", accountsSorted[1].name); }); it("manages the focused state", () => { cy.mount(); /** Test the `focused` state className (applied onFocus) */ - cy.get("select").select(accountsSorted[1].account_sid); - cy.get(".account-filter").should("have.class", "focused"); - cy.get("select").blur(); - cy.get(".account-filter").should("not.have.class", "focused"); + cy.get("input").clear(); + cy.get("input").type(accountsSorted[1].name); + cy.get("input").parent().should("have.class", "focused"); + cy.get("input").blur(); + cy.get("input").parent().should("not.have.class", "focused"); }); it("renders with default option", () => { /** Test with the `defaultOption` prop */ cy.mount(); - /** No default value is set when this prop is present */ - cy.get("select").should("have.value", ""); + cy.get("input").should("have.value", "All accounts"); + }); + + it("verify the typeahead dropdown", () => { + /** Test by typing cus then custom account is selected */ + cy.mount(); + cy.get("input").clear(); + cy.get("input").type("cus"); + cy.get("div#account_filter-option-1").should("have.text", "custom account"); + }); + it("handles Enter key press", () => { + cy.mount(); + + cy.get("input").clear(); + cy.get("input").type("cus{enter}"); + cy.get("input").should("have.value", "custom account"); + }); + it("navigates down and up with arrow keys", () => { + cy.mount(); + + cy.get("input").clear(); + // Press arrow down to move to the first option + cy.get("input").type("{downarrow}"); + cy.get("input").type("{enter}"); + cy.get("input").should("have.value", "default account"); + + // Press up to move to the previous option + cy.get("input").type("{uparrow}"); + cy.get("input").type("{uparrow}"); + cy.get("input").type("{enter}"); + cy.get("input").should("have.value", "custom account"); }); }); diff --git a/src/components/account-filter.tsx b/src/components/account-filter.tsx index 67decb4c..8910e959 100644 --- a/src/components/account-filter.tsx +++ b/src/components/account-filter.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { classNames } from "@jambonz/ui-kit"; -import { Icons } from "src/components/icons"; +import { TypeaheadSelector } from "src/components/forms"; import type { Account } from "src/api/types"; import { hasLength, sortLocaleName } from "src/utils"; @@ -22,12 +22,10 @@ export const AccountFilter = ({ accounts, defaultOption, }: AccountFilterProps) => { - const [focus, setFocus] = useState(false); const classes = { smsel: true, "smsel--filter": true, "account-filter": true, - focused: focus, }; useEffect(() => { @@ -36,41 +34,30 @@ export const AccountFilter = ({ } }, [accounts, defaultOption, setAccountSid]); + const options = [ + ...(defaultOption ? [{ name: "All accounts", value: "" }] : []), + ...(hasLength(accounts) + ? accounts.sort(sortLocaleName).map((acct) => ({ + name: acct.name, + value: acct.account_sid, + })) + : []), + ]; + return (
{label && } -
- - - - - -
+ { + setAccountSid(e.target.value); + setAccountFilter(e.target.value); + }} + />
); }; diff --git a/src/components/forms/account-select.tsx b/src/components/forms/account-select.tsx index bbd1eeb8..e06537f4 100644 --- a/src/components/forms/account-select.tsx +++ b/src/components/forms/account-select.tsx @@ -1,6 +1,6 @@ import React, { useEffect, forwardRef } from "react"; -import { Selector } from "src/components/forms"; +import { TypeaheadSelector } from "src/components/forms"; import type { Account } from "src/api/types"; import { hasLength } from "src/utils"; @@ -16,7 +16,7 @@ type AccountSelectProps = { disabled?: boolean; }; -type SelectorRef = HTMLSelectElement; +type SelectorRef = HTMLInputElement; export const AccountSelect = forwardRef( ( @@ -41,7 +41,7 @@ export const AccountSelect = forwardRef( - {label} {required && *} - ( + ( + { + id, + name, + value = "", + options, + disabled, + onChange, + className, + ...restProps + }: TypeaheadSelectorProps, + ref, + ) => { + const [inputValue, setInputValue] = useState(""); + const [filteredOptions, setFilteredOptions] = useState(options); + const [isOpen, setIsOpen] = useState(false); + const inputRef = React.useRef(null); + const classes = { + "typeahead-selector": true, + [`typeahead-selector${className}`]: true, + focused: isOpen, + disabled: !!disabled, + }; + const [activeIndex, setActiveIndex] = useState(-1); + + /** + * Synchronizes the input field with external value changes + * - Updates the input value when the selected value changes externally + * - Sets the input text to the name of the selected option + * - Updates the active index to match the selected option + * - Runs when either the value prop or options array changes + */ + useEffect(() => { + let selectedIndex = options.findIndex((opt) => opt.value === value); + selectedIndex = selectedIndex < 0 ? 0 : selectedIndex; + const selected = options[selectedIndex]; + setInputValue(selected?.name ?? ""); + setActiveIndex(selectedIndex); + }, [value, options]); + + /** + * Handles changes to the input field value + * @param {React.ChangeEvent} e - Input change event + * + * - Updates the input field with user's typed value + * - Opens the dropdown menu + * - Shows all available options (unfiltered) + * - Finds and highlights the first option that starts with the input text + * - Scrolls the highlighted option into view + */ + const handleInputChange = (e: React.ChangeEvent) => { + const input = e.target.value; + setInputValue(input); + setIsOpen(true); + setFilteredOptions(options); + + const currentIndex = options.findIndex((opt) => + opt.name.toLowerCase().startsWith(input.toLowerCase()), + ); + setActiveIndex(currentIndex); + + // Wait for dropdown to render, then scroll to the selected option + setTimeout(() => { + scrollActiveOptionIntoView(currentIndex); + }, 0); + }; + + /** + * Scrolls the option at the specified index into view within the dropdown + * @param {number} index - The index of the option to scroll into view + * + * - Uses the option's ID to find its DOM element + * - Smoothly scrolls the option into view if found + * - Does nothing if the option element doesn't exist + */ + const scrollActiveOptionIntoView = (index: number) => { + const optionElement = document.getElementById(`${id}-option-${index}`); + if (optionElement) { + optionElement.scrollIntoView({ block: "nearest" }); + } + }; + + /** + * Handles keyboard navigation and selection within the dropdown + * @param {React.KeyboardEvent} e - Keyboard event + * + * Keyboard controls: + * - ArrowDown/ArrowUp: Opens dropdown if closed, otherwise navigates options + * - Enter: Selects the currently highlighted option + * - Escape: Closes the dropdown + * + * Features: + * - Prevents default arrow key scrolling behavior + * - Auto-scrolls the active option into view + * - Wraps navigation within available options + * - Maintains current selection if at list boundaries + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + setIsOpen(true); + setFilteredOptions(options); + return; + } + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((prev) => { + const newIndex = + prev < filteredOptions.length - 1 ? prev + 1 : prev; + scrollActiveOptionIntoView(newIndex); + return newIndex; + }); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((prev) => { + const newIndex = prev > 0 ? prev - 1 : prev; + scrollActiveOptionIntoView(newIndex); + return newIndex; + }); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredOptions.length) { + handleOptionSelect(filteredOptions[activeIndex], e); + } + break; + case "Escape": + setIsOpen(false); + break; + } + }; + + /** + * Handles the selection of an option from the dropdown + * @param {TypeaheadOption} option - The selected option object + * @param {React.MouseEvent | React.KeyboardEvent} e - Optional event object + * + * - Updates the input field with the selected option's name + * - Closes the dropdown + * - Triggers the onChange callback with a synthetic event containing the selected value + */ + const handleOptionSelect = ( + option: TypeaheadOption, + e?: React.MouseEvent | React.KeyboardEvent, + ) => { + e?.preventDefault(); + setInputValue(option.name); + setIsOpen(false); + if (onChange) { + const syntheticEvent = { + target: { value: option.value, name }, + } as React.ChangeEvent; + onChange(syntheticEvent); + } + }; + + /** + * Handles the input focus event + * + * - Opens the dropdown menu + * - Shows all available options (unfiltered) + * - Finds and highlights the currently selected option based on value or input text + * - Scrolls the highlighted option into view after dropdown renders + * + * Note: Uses setTimeout to ensure the dropdown is rendered before attempting to scroll + */ + const handleFocus = () => { + setIsOpen(true); + setFilteredOptions(options); + // Find and highlight the current value in the dropdown + const currentIndex = options.findIndex( + (opt) => opt.value === value || opt.name === inputValue, + ); + setActiveIndex(currentIndex); + + // Wait for dropdown to render, then scroll to the selected option + setTimeout(() => { + scrollActiveOptionIntoView(currentIndex); + }, 0); + }; + + /** + * Handles the input blur (focus loss) event + * @param {React.FocusEvent} e - The blur event object + * + * - Checks if focus is moving outside the component + * - If focus leaves component: + * - Validates current input value against available options + * - Resets input to last valid selection if no match found + * - Closes the dropdown menu + * - Preserves focus state if clicking within component (e.g., dropdown options) + */ + const handleBlur = (e: React.FocusEvent) => { + // Check if the new focus target is within our component + const relatedTarget = e.relatedTarget as Node; + const container = inputRef.current?.parentElement; + + if (!container?.contains(relatedTarget)) { + // Reset value if it doesn't match any option + const matchingOption = options.find( + (opt) => opt.name.toLowerCase() === inputValue.toLowerCase(), + ); + if (!matchingOption) { + const selected = options.find((opt) => opt.value === value); + setInputValue(selected?.name || ""); + } + setIsOpen(false); + } + }; + /** + * Renders a typeahead selector component with dropdown functionality. + * + * Key features: + * - Input field with autocomplete functionality + * - Dropdown toggle button with chevron icons + * - Dropdown list of filterable options + * - Keyboard navigation support + * - Accessibility attributes (ARIA) + * + * Component Structure: + * 1. Input field: + * - Handles text input, focus/blur events + * - Supports both function and object refs + * - Disables browser autocomplete features + * + * 2. Toggle button: + * - Opens/closes dropdown + * - Shows up/down chevron icons + * - Resets filtered options on click + * - Auto-scrolls to selected option + * + * 3. Dropdown menu: + * - Displays filtered options + * - Supports mouse and keyboard interaction + * - Highlights active option + * - Implements proper ARIA attributes for accessibility + * + * States managed: + * - isOpen: Controls dropdown visibility + * - activeIndex: Tracks currently focused option + * - inputValue: Current input text + * - filteredOptions: Available options based on input + */ + return ( +
+ { + // Handle both refs + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + inputRef.current = node; + }} + id={id} + name={name} + value={inputValue} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + onChange={handleInputChange} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + disabled={disabled} + {...restProps} + /> + { + setIsOpen(!isOpen); + setFilteredOptions(options); + const currentIndex = options.findIndex( + (opt) => opt.value === value || opt.name === inputValue, + ); + setActiveIndex(currentIndex); + + // Wait for dropdown to render, then scroll to the selected option + setTimeout(() => { + scrollActiveOptionIntoView(currentIndex); + }, 0); + }} + onKeyDown={handleKeyDown} + > + + + + + {isOpen && ( +
+ {filteredOptions.map((option, index) => ( +
handleOptionSelect(option)} + onMouseEnter={() => setActiveIndex(index)} + > + {option.name} +
+ ))} +
+ )} +
+ ); + }, +); + +TypeaheadSelector.displayName = "TypeaheadSelector"; diff --git a/src/components/forms/typeahead-selector/styles.scss b/src/components/forms/typeahead-selector/styles.scss new file mode 100644 index 00000000..3131b234 --- /dev/null +++ b/src/components/forms/typeahead-selector/styles.scss @@ -0,0 +1,182 @@ +@use "src/styles/vars"; +@use "src/styles/mixins"; +@use "@jambonz/ui-kit/src/styles/index"; +@use "@jambonz/ui-kit/src/styles/vars" as ui-vars; +@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins; + +// ... imports remain the same ... + +// Common mixins for shared styles +@mixin typeahead-base { + position: relative; + max-width: vars.$widthtypeaheadselector; + + &.disabled { + @include mixins.disabled(); + } + + &.focused { + input { + border-color: ui-vars.$dark; + outline: 0; + } + + span { + background-color: ui-vars.$dark; + } + } +} + +@mixin typeahead-input { + @include ui-mixins.m(); + appearance: none; + padding: ui-vars.$px01 ui-vars.$px02; + border-radius: ui-vars.$px01; + border: 2px solid ui-vars.$grey; + background-color: ui-vars.$white; + max-width: vars.$widthtypeaheadinput; + transition: border-color 0.2s ease; + font-family: inherit; + + &:focus { + border-color: ui-vars.$dark; + outline: 0; + } + + &[disabled] { + @include mixins.disabled(); + } +} + +@mixin typeahead-span { + height: 100%; + width: 50px; + background-color: ui-vars.$grey; + border-radius: 0 ui-vars.$px01 ui-vars.$px01 0; + position: absolute; + right: 0; + top: 0; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + transition: background-color 0.2s ease; + + &.disabled { + @include mixins.disabled(); + } + + &.active { + background-color: ui-vars.$dark; + } + + svg { + stroke: ui-vars.$white; + cursor: default; + + &:first-child { + transform: translateY(5px); + } + &:last-child { + transform: translateY(-5px); + } + } +} + +@mixin typeahead-dropdown { + @include ui-mixins.m(); + position: absolute; + top: 100%; + left: 0; + right: 0; + background: ui-vars.$white; + border: 1px solid ui-vars.$dark; + max-height: 200px; + overflow-y: auto; +} + +@mixin typeahead-option { + cursor: pointer; + transition: all 0.2s ease; + font-weight: normal; + display: block; + padding-block-start: 0px; + padding-block-end: 1px; + min-block-size: 1.2em; + padding-inline: 2px; + white-space: nowrap; + padding-left: 16px; + font-family: inherit; + line-height: 30.4px; + + &:hover, + &.active { + background-color: #006dff; + color: ui-vars.$white; + } + + &.active { + cursor: default; + } +} + +// Main classes using the mixins +.typeahead-selector { + @include typeahead-base(); + width: 100%; + + input { + @include typeahead-input(); + width: 100%; + } + + span { + @include typeahead-span(); + } + + .typeahead-dropdown { + @include typeahead-dropdown(); + z-index: 1000; + } + + .typeahead-option { + @include typeahead-option(); + } +} + +.typeahead-selectorsmall { + @include typeahead-base(); + width: auto; + + input { + @include typeahead-input(); + height: 34px; + min-width: 370px; + font-size: var(--mxs-size); + } + + span { + @include typeahead-span(); + } + + .typeahead-dropdown { + @include typeahead-dropdown(); + width: 100%; + } + + .typeahead-option { + @include typeahead-option(); + font-size: var(--mxs-size); + } + + .pointerevents { + pointer-events: all; + cursor: default; + } +} + +.filters--multi { + overflow-x: visible !important; + white-space: nowrap; + grid-gap: 16px; +} diff --git a/src/containers/internal/views/phone-numbers/form.tsx b/src/containers/internal/views/phone-numbers/form.tsx index eda2fc21..df886ff5 100644 --- a/src/containers/internal/views/phone-numbers/form.tsx +++ b/src/containers/internal/views/phone-numbers/form.tsx @@ -10,9 +10,9 @@ import { import { Section } from "src/components"; import { Message, - Selector, AccountSelect, ApplicationSelect, + TypeaheadSelector, } from "src/components/forms"; import { MSG_REQUIRED_FIELDS } from "src/constants"; import { @@ -169,7 +169,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => { -