diff --git a/package.json b/package.json index f314a87b..6967741a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fictoan-react", - "version": "1.10.7", + "version": "1.10.8", "private": false, "description": "A full-featured, designer-friendly, yet performant framework with plain-English props and focus on rapid iteration.", "repository": { diff --git a/src/components/Element/constants.ts b/src/components/Element/constants.ts index 6e6c88f2..4642efcf 100644 --- a/src/components/Element/constants.ts +++ b/src/components/Element/constants.ts @@ -1,4 +1,4 @@ -import { ElementType, HTMLProps } from "react"; +import { ElementType, FormEvent, HTMLProps } from "react"; export const DefaultColours = [ "red", @@ -137,9 +137,16 @@ export interface CommonProps { export interface CommonAndHTMLProps extends CommonProps, Omit, "as" | "size" | "ref" | "shape"> {} +// Fictoan has two different types of event handlers, one for standard events and one for direct values +// This generic event handler type is a union of the two +export type FlexibleEventHandler = + | ((event: T) => void) + | ((value: V) => void); + // prettier-ignore export interface ElementProps extends CommonProps, Omit, "as" | "ref" | "shape"> { as ? : ElementType; className ? : string; ariaLabel ? : string; + onChange ? : FlexibleEventHandler, any>; } diff --git a/src/components/Form/BaseInputComponent/BaseInputComponent.tsx b/src/components/Form/BaseInputComponent/BaseInputComponent.tsx index 001eed53..f7a49803 100644 --- a/src/components/Form/BaseInputComponent/BaseInputComponent.tsx +++ b/src/components/Form/BaseInputComponent/BaseInputComponent.tsx @@ -1,5 +1,5 @@ // FRAMEWORK =========================================================================================================== -import React from "react"; +import React, { FormEvent } from "react"; // FICTOAN ============================================================================================================= import { Element } from "../../Element/Element"; @@ -24,50 +24,59 @@ export const BaseInputComponent = React.forwardRef( validateThis, classNames, children, + onChange, ...inputProps }: BaseInputComponentWithIconProps, ref: React.LegacyRef - ) => ( - - {/* LABEL ////////////////////////////////////////////////////////////////////////////////////////////// */} - {label && } + ) => { + return ( + + {/* LABEL ////////////////////////////////////////////////////////////////////////////////////////////// */} + {label && } - {/* MAIN INPUT ///////////////////////////////////////////////////////////////////////////////////////// */} -
- {/* MAIN INPUT */} - - as={Component} - ref={ref} - classNames={[ - className || "", - validateThis ? "validate-this" : "", - ].concat(classNames || [])} - {...inputProps} - /> - - {children} -
- - {/* INFO SECTION /////////////////////////////////////////////////////////////////////////////////////// */} - {(helpText || errorText) && ( -
- {helpText && ( - - {helpText} - - )} - {errorText && ( - - {errorText} - - )} + {/* MAIN INPUT ///////////////////////////////////////////////////////////////////////////////////////// */} +
+ {/* MAIN INPUT */} + + as={Component} + ref={ref} + classNames={[ + className || "", + validateThis ? "validate-this" : "", + ].concat(classNames || [])} + onChange={onChange} + {...inputProps} + /> + {children}
- )} - - ) + + {/* INFO SECTION /////////////////////////////////////////////////////////////////////////////////////// */} + {(helpText || errorText) && ( +
+ {helpText && ( + + {helpText} + + )} + {errorText && ( + + {errorText} + + )} +
+ )} + + ); + } ) as ( - props: BaseInputComponentWithIconProps & { ref?: React.LegacyRef } + props: BaseInputComponentWithIconProps & { + ref ? : React.LegacyRef + } ) => React.ReactElement; + +function isFormEvent(event: any): event is FormEvent { + return event && "target" in event && "currentTarget" in event; +} diff --git a/src/components/Form/BaseInputComponent/constants.ts b/src/components/Form/BaseInputComponent/constants.ts index 0d01e732..58505d58 100644 --- a/src/components/Form/BaseInputComponent/constants.ts +++ b/src/components/Form/BaseInputComponent/constants.ts @@ -1,4 +1,4 @@ -import React from "react"; +import React, { FormEventHandler } from "react"; import { ElementProps } from "../../Element/constants"; import { InputLabelCustomProps } from "../InputLabel/InputLabel"; @@ -12,6 +12,7 @@ export interface InputCommonProps { invalid ? : boolean; } +// INPUT FIELD PROPS /////////////////////////////////////////////////////////////////////////////////////////////////// // Define allowed combinations for the left side type LeftSideProps = | { iconLeft: React.ReactNode; stringLeft ? : never } @@ -35,11 +36,16 @@ export type NoSideElements = { // Combine left and right side constraints export type InputSideElementProps = LeftSideProps & RightSideProps; -// Base component props including common form input properties +export type FormChangeHandler = FormEventHandler; +export type ValueChangeHandler = (value: string | string[]) => void; +export type FlexibleChangeHandler = FormChangeHandler | ValueChangeHandler; + export type BaseInputComponentProps = - ElementProps & + Omit, "onChange"> & InputLabelCustomProps & - InputCommonProps; + InputCommonProps & { + onChange?: FlexibleChangeHandler; +}; // Extended component props including side element constraints export type BaseInputComponentWithIconProps = diff --git a/src/components/Form/Checkbox/Switch.tsx b/src/components/Form/Checkbox/Switch.tsx index 4841ad83..d2cc54a2 100644 --- a/src/components/Form/Checkbox/Switch.tsx +++ b/src/components/Form/Checkbox/Switch.tsx @@ -11,23 +11,18 @@ import "./switch.css"; // TYPES =============================================================================================================== import { BaseInputComponentProps } from "../BaseInputComponent/constants"; -export interface SwitchCustomProps { - size ? : "small" | "medium" | "large"; -} - export type SwitchElementType = HTMLInputElement; -export type SwitchProps = Omit, keyof SwitchCustomProps | "as"> & - SwitchCustomProps; +export type SwitchProps = Omit, "as">; // COMPONENT /////////////////////////////////////////////////////////////////////////////////////////////////////////// export const Switch = React.forwardRef( - ({ id, name, value, size = "medium", ...props }: SwitchProps, ref: React.Ref) => { + ({ id, name, value, ...props }: SwitchProps, ref: React.Ref) => { // Use ID as default for name and value if they’re not provided const derivedName = useMemo(() => name || id, [name, id]); const derivedValue = useMemo(() => value || id, [value, id]); return ( - as="div" data-switch ref={ref} className={`size-${size}`}> + as="div" data-switch ref={ref}> label::before { + background : var(--checkbox-square-bg-hover); +} + +/* FOCUS */ +[data-form-item]:has(input[type="checkbox"]:focus) > label::before { + outline : solid 2px var(--checkbox-square-bg-disabled); +} + +/* CHECKED */ +[data-form-item]:has(input[type="checkbox"]:checked) > label::before { background : var(--checkbox-square-bg-checked); } +[data-form-item]:has(input[type="checkbox"]:checked) > label::after { opacity : 1; } diff --git a/src/components/Form/Checkbox/switch.css b/src/components/Form/Checkbox/switch.css index fc54abed..e012902d 100644 --- a/src/components/Form/Checkbox/switch.css +++ b/src/components/Form/Checkbox/switch.css @@ -18,13 +18,14 @@ } label { - display : inline-flex; - position : relative; - font-family : var(--paragraph-font); - color : var(--paragraph-text-colour); - cursor : pointer; - line-height : 1; - user-select : none; + display : inline-flex; + position : relative; + font-family : var(--paragraph-font); + color : var(--paragraph-text-colour); + cursor : pointer; + line-height : 1; + user-select : none; + padding-left : 44px; &::before, &::after { @@ -34,149 +35,49 @@ } } - /* The grey square ====================================================== */ - label::before, - input[type="checkbox"]:disabled + label::before { - user-select : none; - pointer-events : none; - background : var(--checkbox-square-bg-default); - box-shadow : 0 2px 4px -2px hsla(0, 0%, 0%, 0.24) inset; + /* The grey stadium shape for the case ================================== */ + label::before { + position : absolute; + top : 50%; + left : 0; + transform : translateY(-50%); + border-radius : 16px; + margin-right : 4px; + background : var(--switch-bg-default); + width : 36px; + height : 18px; } - &:hover label::before { background : var(--checkbox-square-bg-hover); } - input[type="checkbox"]:checked + label::before { background : var(--checkbox-square-bg-checked); } - input[type="checkbox"]:focus + label::before { outline : solid 2px var(--checkbox-square-bg-disabled); } - - label::after { opacity : 0; } - - label { - position : relative; - - /* The grey stadium shape for the case ============================== */ - &::before { - position : absolute; - top : 50%; - left : 0; - transform : translateY(-50%); - border-radius : 16px; - margin-right : 4px; - background : var(--switch-bg-default); - } - - /* The white inner circle actuator ================================== */ - &::after { - content : ""; - position : absolute; - top : 50%; - transform : translateY(-50%); - display : inline-block; - background : var(--switch-slider-bg-default); - border-radius : 50%; - transition : all 0.1s ease-out; - box-shadow : 0 2px 4px -2px hsla(0, 0%, 0%, 0.6); - opacity : 1; - } - } - - /* States for the stadium */ - input[type="checkbox"]:hover + label::before { - background : var(--switch-bg-hover); - } - - input[type="checkbox"]:checked + label::before { - background : var(--switch-bg-checked); - } - - /* States for the circle */ - input[type="checkbox"]:hover + label::after { - background : var(--switch-slider-bg-hover); - } - - input[type="checkbox"]:checked + label::after { - background : var(--switch-slider-bg-checked); - } - - /* DISABLED ============================================================= */ - input[type="checkbox"]:disabled + label, - input[type="checkbox"]:disabled + label::before, - input[type="checkbox"]:disabled + label::after { - filter : grayscale(24%); - user-select : none; - pointer-events : none; - cursor : not-allowed; - } - - /* SIZES ================================================================ */ - &.size-small { - label { - padding-left : 32px; - - &::before { - width : 24px; - height : 12px; - } - - &::after { - left : 2px; - width : 8px; - height : 8px; - } - } - - input[type="checkbox"]:checked + label::after { - transform : translateY(-50%) translateX(12px); - } - } - - &.size-medium { - label { - padding-left : 44px; - - &::before { - width : 36px; - height : 18px; - } - - &::after { - left : 3px; - width : 12px; - height : 12px; - } - } - - input[type="checkbox"]:checked + label::after { - transform : translateY(-50%) translateX(18px); - } + /* The white inner circle actuator ====================================== */ + label::after { + position : absolute; + top : 50%; + transform : translateY(-50%); + display : inline-block; + background : var(--switch-slider-bg-default); + border-radius : 50%; + box-shadow : 0 2px 4px -2px hsla(0, 0%, 0%, 0.6); + left : 3px; + width : 12px; + height : 12px; } +} - &.size-large { - label { - padding-left : 56px; - - &::before { - width : 48px; - height : 24px; - } +/* HOVER */ +[data-switch]:has(input[type="checkbox"]:hover) > label::before { + background : var(--switch-bg-hover); +} - &::after { - left : 4px; - width : 16px; - height : 16px; - } - } +[data-switch]:has(input[type="checkbox"]:hover) > label::after { + background : var(--switch-slider-bg-hover); +} - input[type="checkbox"]:checked + label::after { - transform : translateY(-50%) translateX(24px); - } - } +/* CHECKED */ +[data-switch]:has(input[type="checkbox"]:checked) > label::before { + background : var(--switch-bg-checked); +} - /* MARGINS ============================================================== */ - [data-form-wrapper].spacing-none & { margin-bottom : 0; } - [data-form-wrapper].spacing-nano & { margin-bottom : 8px; } - [data-form-wrapper].spacing-micro & { margin-bottom : 12px; } - [data-form-wrapper].spacing-tiny & { margin-bottom : 16px; } - [data-form-wrapper].spacing-small & { margin-bottom : 24px; } - [data-form-wrapper].spacing-medium & { margin-bottom : 32px; } - [data-form-wrapper].spacing-large & { margin-bottom : 40px; } - [data-form-wrapper].spacing-huge & { margin-bottom : 48px; } +[data-switch] [data-form-item]:has(input[type="checkbox"]:checked) > label::after { + transform : translateY(-50%) translateX(18px); + background : var(--switch-slider-bg-checked); } diff --git a/src/components/Form/FormItem/form-item.css b/src/components/Form/FormItem/form-item.css index 36ea263a..c006aea7 100644 --- a/src/components/Form/FormItem/form-item.css +++ b/src/components/Form/FormItem/form-item.css @@ -108,14 +108,6 @@ } } -[data-form-item] input[type="radio"]:disabled + label, -[data-form-item] input[type="checkbox"]:disabled + label { - pointer-events : none; - user-select : none; - cursor : default; - opacity : 0.36; -} - [data-form-item] [data-input-field], [data-form-item] [data-text-area], [data-form-item] [data-select-wrapper] { @@ -152,3 +144,14 @@ } } } + +/* DISABLED */ +[data-form-item]:has(input:disabled) > label, +[data-form-item]:has(input:disabled) > label::before, +[data-form-item]:has(input:disabled) > label::after { + filter : grayscale(24%); + user-select : none; + pointer-events : none; + cursor : not-allowed; + opacity : 0.64; +} diff --git a/src/components/Form/InputField/InputField.tsx b/src/components/Form/InputField/InputField.tsx index 2fea4947..0bd85607 100644 --- a/src/components/Form/InputField/InputField.tsx +++ b/src/components/Form/InputField/InputField.tsx @@ -66,7 +66,6 @@ export const InputField = React.forwardRef( ); }; - const hasLeftElement = Boolean(iconLeft || stringLeft); const hasRightElement = Boolean(iconRight || stringRight); diff --git a/src/components/Form/ListBox/ListBox.tsx b/src/components/Form/ListBox/ListBox.tsx index 5342d05c..cb9ca8d8 100644 --- a/src/components/Form/ListBox/ListBox.tsx +++ b/src/components/Form/ListBox/ListBox.tsx @@ -1,5 +1,12 @@ // FRAMEWORK =========================================================================================================== -import React, { useState, useRef, useEffect, MutableRefObject, KeyboardEvent } from "react"; +import React, { + useState, + useRef, + useEffect, + MutableRefObject, + FormEvent, + KeyboardEvent, +} from "react"; // FICTOAN ============================================================================================================= import { Div } from "../../Element/Tags"; @@ -8,391 +15,327 @@ import { Badge } from "../../Badge/Badge"; import { Text } from "../../Typography/Text"; import { BaseInputComponent } from "../BaseInputComponent/BaseInputComponent"; -// STYLES ============================================================================================================== -import "./list-box.css"; - // HOOKS =============================================================================================================== import { useClickOutside } from "../../../hooks/UseClickOutside"; // UTILS =============================================================================================================== import { searchOptions } from "./listBoxUtils"; -// TYPES =============================================================================================================== -import { - ListBoxProps, - OptionForListBoxProps, - ListBoxElementType, -} from "./constants"; - -// COMPONENT /////////////////////////////////////////////////////////////////////////////////////////////////////////// -export const ListBox = React.forwardRef( - ( - { - options, - label, - placeholder = "Select an option", - id, - defaultValue, - onChange, - allowMultiSelect = false, - disabled, - badgeBgColour, - badgeBgColor, - badgeTextColour, - badgeTextColor, - selectionLimit, - allowCustomEntries = false, - ...props - }, ref) => { - // STATES ==================================================================================================== - const [ isOpen, setIsOpen ] = useState(false); - const [ selectedOptions, setSelectedOptions ] = useState([]); - const [ searchValue, setSearchValue ] = useState(""); - const [ activeIndex, setActiveIndex ] = useState(-1); - - // Initialize selectedOption based on defaultValue - const [selectedOption, setSelectedOption] = useState(() => { - if (defaultValue) { - return options.find(opt => opt.value === defaultValue) || null; - } - return null; - }); - - // Set initial value - useEffect(() => { - if (defaultValue && onChange) { - onChange(defaultValue); - } - }, []); - - // REFS ===================================================================================================== - const dropdownRef = useRef() as MutableRefObject; - const searchInputRef = useRef(null); - const effectiveRef = (ref || dropdownRef) as React.RefObject; - - // CONSTANTS ================================================================================================ - const listboxId = id || `listbox-${Math.random().toString(36).substring(2, 9)}`; - const filteredOptions = searchOptions(options, searchValue); - - // HANDLERS ================================================================================================= - const handleSelectOption = (option: OptionForListBoxProps) => { - if (option.disabled) return; +// STYLES ============================================================================================================== +import "./list-box.css"; - let newSelectedOptions: OptionForListBoxProps[]; - if (allowMultiSelect) { - const isSelected = selectedOptions.some(opt => opt.value === option.value); - if (isSelected) { - newSelectedOptions = selectedOptions.filter(opt => opt.value !== option.value); - } else { - // Check for selection limit before adding new option - if (selectionLimit && selectedOptions.length >= selectionLimit) { - return; // Don't add more if limit is reached - } - newSelectedOptions = [ ...selectedOptions, option ]; - } - setSelectedOptions(newSelectedOptions); - onChange?.(newSelectedOptions.map(opt => opt.value)); - setSearchValue(""); - setActiveIndex(-1); +// TYPES =============================================================================================================== +import { FormChangeHandler, ValueChangeHandler } from "../BaseInputComponent/constants"; +import { ListBoxProps, OptionForListBoxProps, ListBoxElementType } from "./constants"; + +// Internal ListBox component similar to SelectWithOptions +const ListBoxWithOptions = ( + { + id, + options, + placeholder = "Select an option", + defaultValue, + onChange, + allowMultiSelect = false, + disabled, + badgeBgColour, + badgeBgColor, + badgeTextColour, + badgeTextColor, + selectionLimit, + allowCustomEntries = false, + className, + ...props + }: Omit) => { + // STATES ==================================================================================================== + const [ isOpen, setIsOpen ] = useState(false); + const [ selectedOptions, setSelectedOptions ] = useState([]); + const [ searchValue, setSearchValue ] = useState(""); + const [ activeIndex, setActiveIndex ] = useState(-1); + + // Initialize selectedOption based on defaultValue + const [ selectedOption, setSelectedOption ] = useState(() => { + if (defaultValue) { + return options.find(opt => opt.value === defaultValue) || null; + } + return null; + }); + + // Set initial value + useEffect(() => { + if (defaultValue) { + handleChange(defaultValue); + } + }, []); + + // REFS ===================================================================================================== + const dropdownRef = useRef() as MutableRefObject; + const searchInputRef = useRef(null); + + // CONSTANTS =============================================================================================== + const listboxId = id || `listbox-${Math.random().toString(36).substring(2, 9)}`; + const filteredOptions = searchOptions(options, searchValue); + + // HANDLERS ================================================================================================ + const handleChange = (value: string | string[]) => { + if (!onChange) return; + + const target = { value }; + const event = { + target, + currentTarget: target, + } as unknown as FormEvent; + + onChange(event); + }; + + const handleSelectOption = (option: OptionForListBoxProps) => { + if (option.disabled) return; + + let newSelectedOptions: OptionForListBoxProps[]; + if (allowMultiSelect) { + const isSelected = selectedOptions.some(opt => opt.value === option.value); + if (isSelected) { + newSelectedOptions = selectedOptions.filter(opt => opt.value !== option.value); } else { - newSelectedOptions = [ option ]; - setSelectedOption(option); - setSelectedOptions(newSelectedOptions); - onChange?.(option.value); - setIsOpen(false); - setSearchValue(""); - setActiveIndex(-1); + if (selectionLimit && selectedOptions.length >= selectionLimit) { + return; + } + newSelectedOptions = [ ...selectedOptions, option ]; } - }; - - // REMOVE SELECTED ENTRIES ===================================================================================== - const handleDeleteOption = (e: React.MouseEvent, valueToRemove: string) => { - e.stopPropagation(); - const newSelectedOptions = selectedOptions.filter(opt => opt.value !== valueToRemove); setSelectedOptions(newSelectedOptions); - onChange?.(newSelectedOptions.map(opt => opt.value)); - }; - - // ALLOW USER TO TYPE IN A CUSTOM OPTION ======================================================================= - const handleCustomEntry = () => { - if (!searchValue.trim() || !allowCustomEntries) return; - - const customOption: OptionForListBoxProps = { - value : searchValue.trim(), - label : searchValue.trim(), - }; + handleChange(newSelectedOptions.map(opt => opt.value)); + } else { + newSelectedOptions = [ option ]; + setSelectedOption(option); + setSelectedOptions(newSelectedOptions); + handleChange(option.value); + setIsOpen(false); + } + setSearchValue(""); + setActiveIndex(-1); + }; - handleSelectOption(customOption); - setSearchValue(""); - setActiveIndex(-1); - }; + const handleCustomEntry = () => { + if (!searchValue.trim() || !allowCustomEntries) return; - // REMOVE ALL CURRENTLY SELECTED OPTIONS ======================================================================= - const handleClearAll = (e: React.MouseEvent) => { - e.stopPropagation(); - setSelectedOptions([]); - onChange?.([]); + const customOption: OptionForListBoxProps = { + value : searchValue.trim(), + label : searchValue.trim(), }; - // KEYBOARD NAVIGATION OF THE OPTIONS DROPDOWN ================================================================= - const handleKeyDown = (event: KeyboardEvent) => { - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - if (!isOpen) { - setIsOpen(true); - setActiveIndex(0); + handleSelectOption(customOption); + setSearchValue(""); + setActiveIndex(-1); + }; + + const handleDeleteOption = (e: React.MouseEvent, valueToRemove: string) => { + e.stopPropagation(); + const newSelectedOptions = selectedOptions.filter(opt => opt.value !== valueToRemove); + setSelectedOptions(newSelectedOptions); + handleChange(newSelectedOptions.map(opt => opt.value)); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + setActiveIndex(0); + } else { + setActiveIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : prev, + ); + } + break; + + case "ArrowUp": + event.preventDefault(); + setActiveIndex(prev => prev > 0 ? prev - 1 : prev); + break; + + case "Enter": + event.preventDefault(); + if (allowCustomEntries && searchValue.trim()) { + const exactMatch = filteredOptions.find(opt => + opt.label.toLowerCase() === searchValue.trim().toLowerCase(), + ); + if (exactMatch) { + handleSelectOption(exactMatch); } else { - setActiveIndex(prev => - prev < filteredOptions.length - 1 ? prev + 1 : prev, - ); + handleCustomEntry(); } - break; - - case "ArrowUp": - event.preventDefault(); - setActiveIndex(prev => prev > 0 ? prev - 1 : prev); - break; - - case "Enter": - event.preventDefault(); - if (allowCustomEntries && searchValue.trim()) { - // First check if the search value exactly matches any option - const exactMatch = filteredOptions.find(opt => - opt.label.toLowerCase() === searchValue.trim().toLowerCase(), - ); + } else if (activeIndex >= 0 && filteredOptions[activeIndex]) { + handleSelectOption(filteredOptions[activeIndex]); + } + break; - if (exactMatch) { - handleSelectOption(exactMatch); - } else { - handleCustomEntry(); - } - } else if (activeIndex >= 0 && filteredOptions[activeIndex]) { - handleSelectOption(filteredOptions[activeIndex]); - } - break; + case "Escape": + event.preventDefault(); + setIsOpen(false); + setActiveIndex(-1); + break; - case "Escape": + case " ": + if (!isOpen) { event.preventDefault(); - setIsOpen(false); - setActiveIndex(-1); - break; - - case " ": // Space key - if (!isOpen) { - event.preventDefault(); - setIsOpen(true); - setActiveIndex(0); - } - break; - - case "Home": - if (isOpen) { - event.preventDefault(); - setActiveIndex(0); - } - break; - - case "End": - if (isOpen) { - event.preventDefault(); - setActiveIndex(filteredOptions.length - 1); - } - break; - } - }; - - // EFFECTS ================================================================================================== - useClickOutside(effectiveRef, () => { - setIsOpen(false); - setActiveIndex(-1); - }); - - // Focus management - useEffect(() => { - if (isOpen && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [ isOpen ]); - - // Scroll active option into view - useEffect(() => { - if (activeIndex >= 0) { - const activeOption = document.querySelector(`[data-index="${activeIndex}"]`); - activeOption?.scrollIntoView({ block : "nearest" }); - } - }, [ activeIndex ]); - - // RENDER =================================================================================================== - return ( - - data-list-box - className={`list-box-wrapper ${disabled ? "disabled" : ""}`} - aria-disabled={disabled} - ref={effectiveRef} - {...props} + setIsOpen(true); + setActiveIndex(0); + } + break; + } + }; + + // Click outside handling + useClickOutside(dropdownRef, () => { + setIsOpen(false); + setActiveIndex(-1); + }); + + // Focus management + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [ isOpen ]); + + // RENDER ================================================================================================== + return ( +
+
!disabled && setIsOpen(!isOpen)} + role="combobox" + aria-haspopup="listbox" + aria-expanded={isOpen} + aria-controls={`${listboxId}-listbox`} + aria-owns={`${listboxId}-listbox`} + tabIndex={disabled ? -1 : 0} > - {/* TOP LABEL ////////////////////////////////////////////////////////////////////////////////////// */} - {label && ( - - )} - - {/* MAIN CONTAINER ///////////////////////////////////////////////////////////////////////////////// */} -
!disabled && setIsOpen(!isOpen)} - onKeyDown={handleKeyDown} - role="combobox" - aria-haspopup="listbox" - aria-expanded={isOpen} - aria-labelledby={`${listboxId}-label`} - aria-controls={`${listboxId}-listbox`} - aria-owns={`${listboxId}-listbox`} - tabIndex={disabled ? -1 : 0} - > - {allowMultiSelect ? ( - <> - {selectedOptions.length > 0 ? ( -
-
- {selectedOptions.map(option => ( - handleDeleteOption(e, option.value)} - size="small" - shape="rounded" - bgColour={badgeBgColour || badgeBgColor} - textColour={badgeTextColour || badgeTextColor} - > - {option.label} - - ))} -
- - {/* LIMIT WARNING */} - {selectionLimit && selectedOptions.length >= selectionLimit && ( - + {selectedOptions.length > 0 ? ( +
+
+ {selectedOptions.map(option => ( + handleDeleteOption(e, option.value)} size="small" - role="alert" + shape="rounded" + bgColour={badgeBgColour || badgeBgColor} + textColour={badgeTextColour || badgeTextColor} > - You can choose only {selectionLimit} option{selectionLimit === 1 ? "" : "s"} - - )} + {option.label} + + ))}
- ) : ( - {placeholder} - )} - - {selectedOptions.length > 0 && ( -
- -
- )} - - ) : ( - // FOR PLAIN TEXT SINGLE-SELECT OPTION ========================================================= - selectedOption - ? {selectedOption.label} - : {placeholder} - )} + {selectionLimit && selectedOptions.length >= selectionLimit && ( + + You can choose only {selectionLimit} option{selectionLimit === 1 ? "" : "s"} + + )} +
+ ) : ( + {placeholder} + )} + + ) : ( + selectedOption + ? {selectedOption.label} + : {placeholder} + )} -
- -
+
+
+
+ + {isOpen && !disabled && ( +
+
+ { + setSearchValue((e.target as HTMLInputElement).value); + }} + onKeyDown={handleKeyDown} + aria-controls={`${listboxId}-listbox`} + aria-label="Search options" + /> + {allowCustomEntries && searchValue.trim() && !selectedOptions.some(opt => + opt.label.toLowerCase() === searchValue.trim().toLowerCase()) && ( + + ↵ + + )} +
- {/* DROPDOWN /////////////////////////////////////////////////////////////////////////////////////// */} - {isOpen && !disabled && ( - - )} - - ); - }, -); + )) + ) : ( +
  • + No matches found +
  • + )} + +
    + )} +
    + ); +}; + +// Main ListBox component +export const ListBox = React.forwardRef((props, ref) => { + return ( + + as={ListBoxWithOptions} + ref={ref} + {...props} + /> + ); +}); diff --git a/src/components/Form/ListBox/constants.ts b/src/components/Form/ListBox/constants.ts index 923b7e6d..bbd8fd76 100644 --- a/src/components/Form/ListBox/constants.ts +++ b/src/components/Form/ListBox/constants.ts @@ -1,4 +1,4 @@ -import React from "react"; +import React, { FormEvent } from "react"; import { ColourPropTypes, CommonAndHTMLProps } from "@/components/Element/constants"; type NonZeroNumber = Exclude; @@ -18,7 +18,6 @@ export interface ListBoxCustomProps { helpText ? : string; errorText ? : string; allowMultiSelect ? : boolean; - onChange ? : (value: string | string[]) => void; disabled ? : boolean; placeholder ? : string; id ? : string; @@ -30,6 +29,7 @@ export interface ListBoxCustomProps { selectionLimit ? : NonZeroNumber; allowCustomEntries ? : boolean; isLoading ? : boolean; + onChange ? : (event: FormEvent) => void; } export type ListBoxProps = diff --git a/src/components/Form/RadioButton/radio-button.css b/src/components/Form/RadioButton/radio-button.css index 75b745d2..66ee745e 100644 --- a/src/components/Form/RadioButton/radio-button.css +++ b/src/components/Form/RadioButton/radio-button.css @@ -25,7 +25,7 @@ background : var(--radio-circle-bg-hover); } - /* The white inner circle */ + /* The white inner circle */ & label::after { opacity : 0; left : 4px; @@ -36,21 +36,6 @@ border-radius : 50%; } - &:checked + label::before, - input[type="radio"]:checked + label::before { - background : var(--radio-circle-bg-checked); - } - - &:checked + label::after, - input[type="radio"]:checked + label::after { - opacity : 1; - } - - &:focus + label::before, - input[type="radio"]:focus + label::before { - outline : solid 2px var(--radio-circle-bg-checked); - } - &:only-of-type { margin-right : 0; } @@ -80,3 +65,16 @@ box-shadow : inset 0 2px 8px -2px rgba(0, 0, 0, 0.24); } } + +/* STATES =================================================================== */ +[data-form-item]:has(input[type="radio"]:checked) > label::before { + background : var(--radio-circle-bg-checked); +} + +[data-form-item]:has(input[type="radio"]:checked) > label::after { + opacity : 1; +} + +[data-form-item]:has(input[type="radio"]:focus) > label::before { + outline : solid 2px var(--radio-circle-bg-checked); +} diff --git a/src/styles/theme.css b/src/styles/theme.css index 6d9c3f4b..a1ccd2d4 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -580,8 +580,8 @@ --screen-width-max : 1600; --font-size-min : 16; --font-size-max : 20; - --scale-ratio-min : 1.12; - --scale-ratio-max : 1.16; + --scale-ratio-min : 1.08; + --scale-ratio-max : 1.12; } :root {