mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2026-07-04 19:21:58 +00:00
Feat/473: TypeaheadSelector component (#474)
* feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components * making selectedIndex as 0 if it is below 0 * feat/473: fixed styles * made carrier selector as typeahead selector * converted account-filter to use typeahead-selector * styles refactoring * updated test cases * added typeahead test case * added more test cases for typeahead account filter * feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components * making selectedIndex as 0 if it is below 0 * feat/473: fixed styles * made carrier selector as typeahead selector * converted account-filter to use typeahead-selector * styles refactoring * updated test cases * added typeahead test case * added more test cases for typeahead account filter
This commit is contained in:
@@ -43,32 +43,63 @@ describe("<AccountFilter>", () => {
|
||||
cy.mount(<AccountFilterTestWrapper />);
|
||||
|
||||
/** 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(<AccountFilterTestWrapper />);
|
||||
|
||||
/** 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(<AccountFilterTestWrapper />);
|
||||
|
||||
/** 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(<AccountFilterTestWrapper defaultOption />);
|
||||
|
||||
/** 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(<AccountFilterTestWrapper defaultOption />);
|
||||
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(<AccountFilterTestWrapper />);
|
||||
|
||||
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(<AccountFilterTestWrapper />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className={classNames(classes)}>
|
||||
{label && <label htmlFor="account_filter">{label}:</label>}
|
||||
<div>
|
||||
<select
|
||||
id="account_filter"
|
||||
name="account_filter"
|
||||
value={accountSid}
|
||||
onChange={(e) => {
|
||||
setAccountSid(e.target.value);
|
||||
setAccountFilter(e.target.value);
|
||||
}}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
>
|
||||
{defaultOption ? (
|
||||
<option value="">All accounts</option>
|
||||
) : (
|
||||
accounts &&
|
||||
!accounts.length && <option value="">No accounts</option>
|
||||
)}
|
||||
{hasLength(accounts) &&
|
||||
accounts.sort(sortLocaleName).map((acct) => {
|
||||
return (
|
||||
<option key={acct.account_sid} value={acct.account_sid}>
|
||||
{acct.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<span>
|
||||
<Icons.ChevronUp />
|
||||
<Icons.ChevronDown />
|
||||
</span>
|
||||
</div>
|
||||
<TypeaheadSelector
|
||||
id="account_filter"
|
||||
name="account_filter"
|
||||
value={accountSid}
|
||||
options={options}
|
||||
className="small"
|
||||
onChange={(e) => {
|
||||
setAccountSid(e.target.value);
|
||||
setAccountFilter(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SelectorRef, AccountSelectProps>(
|
||||
(
|
||||
@@ -41,7 +41,7 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
|
||||
<label htmlFor="account_sid">
|
||||
{label} {required && <span>*</span>}
|
||||
</label>
|
||||
<Selector
|
||||
<TypeaheadSelector
|
||||
ref={ref}
|
||||
id="account_sid"
|
||||
name="account_sid"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, forwardRef } from "react";
|
||||
|
||||
import { Selector } from "src/components/forms";
|
||||
import { TypeaheadSelector } from "src/components/forms";
|
||||
import { hasLength } from "src/utils";
|
||||
|
||||
import type { Application } from "src/api/types";
|
||||
@@ -18,7 +18,7 @@ type ApplicationSelectProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectorRef = HTMLSelectElement;
|
||||
type SelectorRef = HTMLInputElement;
|
||||
|
||||
export const ApplicationSelect = forwardRef<
|
||||
SelectorRef,
|
||||
@@ -47,7 +47,7 @@ export const ApplicationSelect = forwardRef<
|
||||
<label htmlFor={id}>
|
||||
{label} {required && <span>*</span>}
|
||||
</label>
|
||||
<Selector
|
||||
<TypeaheadSelector
|
||||
ref={ref}
|
||||
id={id}
|
||||
name={id}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FileUpload } from "./file-upload";
|
||||
import { AccountSelect } from "./account-select";
|
||||
import { ApplicationSelect } from "./application-select";
|
||||
import { LocalLimits, useLocalLimitsRef } from "./local-limits";
|
||||
import { TypeaheadSelector } from "./typeahead-selector";
|
||||
|
||||
export {
|
||||
Passwd,
|
||||
@@ -17,4 +18,5 @@ export {
|
||||
ApplicationSelect,
|
||||
LocalLimits,
|
||||
useLocalLimitsRef,
|
||||
TypeaheadSelector,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React, { useState, forwardRef, useEffect } from "react";
|
||||
import { classNames } from "@jambonz/ui-kit";
|
||||
import { Icons } from "src/components/icons";
|
||||
|
||||
import "./styles.scss";
|
||||
|
||||
/**
|
||||
* Represents an option in the typeahead selector dropdown
|
||||
* @interface TypeaheadOption
|
||||
* @property {string} name - The display text shown in the dropdown
|
||||
* @property {string} value - The underlying value used when the option is selected
|
||||
*/
|
||||
export interface TypeaheadOption {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the TypeaheadSelector component
|
||||
* @extends {JSX.IntrinsicElements["input"]} - Inherits all standard HTML input props
|
||||
* @typedef TypeaheadSelectorProps
|
||||
* @property {TypeaheadOption[]} options - Array of selectable options to display in the dropdown
|
||||
* @property {string} [className] - Optional CSS class name to apply to the component
|
||||
*/
|
||||
type TypeaheadSelectorProps = JSX.IntrinsicElements["input"] & {
|
||||
options: TypeaheadOption[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TypeaheadSelectorRef = HTMLInputElement;
|
||||
|
||||
/**
|
||||
* TypeaheadSelector - A searchable dropdown component with keyboard navigation
|
||||
*
|
||||
* @component
|
||||
* @param {Object} props
|
||||
* @param {string} props.id - Unique identifier for the input
|
||||
* @param {string} props.name - Form field name
|
||||
* @param {string} props.value - Currently selected value
|
||||
* @param {TypeaheadOption[]} props.options - Array of selectable options
|
||||
* @param {boolean} props.disabled - Whether the input is disabled
|
||||
* @param {Function} props.onChange - Callback when selection changes
|
||||
* @param {Ref} ref - Forwarded ref for the input element
|
||||
*
|
||||
* Features:
|
||||
* - Keyboard navigation (up/down arrows, enter to select, escape to close)
|
||||
* - Auto-scroll selected option into view
|
||||
* - Filtering options by typing
|
||||
* - Click or keyboard selection
|
||||
* - Maintains value synchronization with parent component
|
||||
* - Accessibility support with ARIA attributes
|
||||
*/
|
||||
export const TypeaheadSelector = forwardRef<
|
||||
TypeaheadSelectorRef,
|
||||
TypeaheadSelectorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
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<HTMLInputElement | null>(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<HTMLInputElement>} 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>} 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>;
|
||||
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 (
|
||||
<div className={classNames(classes)}>
|
||||
<input
|
||||
className={classNames({
|
||||
active: isOpen,
|
||||
disabled: !!disabled,
|
||||
})}
|
||||
ref={(node) => {
|
||||
// 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}
|
||||
/>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onBlur={handleBlur}
|
||||
className={classNames({
|
||||
active: isOpen,
|
||||
disabled: !!disabled,
|
||||
pointerevents: true,
|
||||
})}
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
<Icons.ChevronUp />
|
||||
<Icons.ChevronDown />
|
||||
</span>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="typeahead-dropdown"
|
||||
role="listbox"
|
||||
id={`${id}-listbox`}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${id}_${option.value}`}
|
||||
className={classNames({
|
||||
"typeahead-option": true,
|
||||
active: index === activeIndex,
|
||||
})}
|
||||
role="option"
|
||||
id={`${id}-option-${index}`}
|
||||
aria-selected={index === activeIndex}
|
||||
tabIndex={-1}
|
||||
onMouseDown={() => handleOptionSelect(option)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
{option.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TypeaheadSelector.displayName = "TypeaheadSelector";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
<label htmlFor="sip_trunk">
|
||||
Carrier <span>*</span>
|
||||
</label>
|
||||
<Selector
|
||||
<TypeaheadSelector
|
||||
id="sip_trunk"
|
||||
name="sip_trunk"
|
||||
required
|
||||
|
||||
@@ -12,6 +12,8 @@ $widthnavi: 280px;
|
||||
$widthinput: 512px;
|
||||
$widthbreak: 800px;
|
||||
$widthsmall: 640px;
|
||||
$widthtypeaheadselector: 512px;
|
||||
$widthtypeaheadinput: 467px;
|
||||
|
||||
/** Used by api-keys layout */
|
||||
$gridbreak1: 1070px;
|
||||
|
||||
Reference in New Issue
Block a user