Selectors, style generalization, classes merging generalization, background colors.
This commit is contained in:
		
							parent
							
								
									08ba629b78
								
							
						
					
					
						commit
						e15861393e
					
				
					 26 changed files with 717 additions and 27 deletions
				
			
		|  | @ -10,11 +10,14 @@ import {Float} from "../src/Components/Floating/Float"; | ||||||
| import {Tooltip} from "../src/Components/Floating/Tooltip"; | import {Tooltip} from "../src/Components/Floating/Tooltip"; | ||||||
| import {DatepickerInput} from "../src/Components/Forms/DatepickerInput"; | import {DatepickerInput} from "../src/Components/Forms/DatepickerInput"; | ||||||
| import {TimepickerInput} from "../src/Components/Forms/TimepickerInput"; | import {TimepickerInput} from "../src/Components/Forms/TimepickerInput"; | ||||||
|  | import {Select} from "../src/Components/Select/Select"; | ||||||
| 
 | 
 | ||||||
| export function DemoApp() | export function DemoApp() | ||||||
| { | { | ||||||
| 	const [datetime, setDatetime] = useState(null); | 	const [datetime, setDatetime] = useState(null); | ||||||
| 
 | 
 | ||||||
|  | 	const [selected, setSelected] = useState(null); | ||||||
|  | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<main className={"app"}> | 		<main className={"app"}> | ||||||
| 			<h1>KernelUI</h1> | 			<h1>KernelUI</h1> | ||||||
|  | @ -22,11 +25,9 @@ export function DemoApp() | ||||||
| 			<h2>TODO</h2> | 			<h2>TODO</h2> | ||||||
| 
 | 
 | ||||||
| 			<ul> | 			<ul> | ||||||
| 				<li>Popover / Tooltips</li> |  | ||||||
| 				<li>Datepicker / Timepicker</li> |  | ||||||
| 				<li>Selects</li> |  | ||||||
| 				<li>Loaders</li> | 				<li>Loaders</li> | ||||||
| 				<li>Menu</li> | 				<li>Dropdown menus</li> | ||||||
|  | 				<li>Main menu</li> | ||||||
| 				<li>Tabs / Apps selectors</li> | 				<li>Tabs / Apps selectors</li> | ||||||
| 				<li>App steps</li> | 				<li>App steps</li> | ||||||
| 				<li>Pagination</li> | 				<li>Pagination</li> | ||||||
|  | @ -90,6 +91,17 @@ export function DemoApp() | ||||||
| 				<Checkbox>Checkbox demo</Checkbox> | 				<Checkbox>Checkbox demo</Checkbox> | ||||||
| 				<Radio name={"radio-test"}>Radio box test</Radio> | 				<Radio name={"radio-test"}>Radio box test</Radio> | ||||||
| 				<Radio name={"radio-test"}>Radio box test</Radio> | 				<Radio name={"radio-test"}>Radio box test</Radio> | ||||||
|  | 
 | ||||||
|  | 				<Select options={{ | ||||||
|  | 					"a": "AAAAAA", | ||||||
|  | 					"b": "BBBBBB", | ||||||
|  | 					"c": "CCCCCC", | ||||||
|  | 					"d": "DDDDDD", | ||||||
|  | 					"e": "EEEEEE", | ||||||
|  | 					"f": "FFFFFF", | ||||||
|  | 				}} value={selected} onChange={setSelected} selectibleMaxCount={3} placeholder={"Simple test"}> | ||||||
|  | 					Simple select test | ||||||
|  | 				</Select> | ||||||
| 			</form> | 			</form> | ||||||
| 
 | 
 | ||||||
| 			<h2>HTML</h2> | 			<h2>HTML</h2> | ||||||
|  |  | ||||||
|  | @ -1,9 +1,10 @@ | ||||||
| import React, {PropsWithChildren} from "react"; | import React, {PropsWithChildren} from "react"; | ||||||
|  | import {classes} from "../Utils"; | ||||||
| 
 | 
 | ||||||
| export function Card({children, className, ...props}: PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>): React.ReactElement | export function Card({children, className, ...props}: PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>): React.ReactElement | ||||||
| { | { | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className={`card${className ? ` ${className}` : ""}`} {...props}> | 		<div className={classes("card", className)} {...props}> | ||||||
| 			{children} | 			{children} | ||||||
| 		</div> | 		</div> | ||||||
| 	); | 	); | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| import React, {useMemo} from "react"; | import React, {useMemo} from "react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Calendar component. |  * Calendar component. | ||||||
|  | @ -75,7 +76,7 @@ export function Calendar({date, onDateSelected, locale, className, ...tableProps | ||||||
| 	}, [date, onDateSelected]); | 	}, [date, onDateSelected]); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<table className={`calendar${className ? ` ${className}` : ""}`} {...tableProps}> | 		<table className={classes("calendar", className)} {...tableProps}> | ||||||
| 			<thead> | 			<thead> | ||||||
| 			{currentMonthHeader} | 			{currentMonthHeader} | ||||||
| 			</thead> | 			</thead> | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import React, {useCallback, useMemo} from "react"; | ||||||
| import {CaretLeft, CaretRight} from "@phosphor-icons/react"; | import {CaretLeft, CaretRight} from "@phosphor-icons/react"; | ||||||
| import {Tooltip} from "../Floating/Tooltip"; | import {Tooltip} from "../Floating/Tooltip"; | ||||||
| import {Calendar} from "./Calendar"; | import {Calendar} from "./Calendar"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Datepicker component. |  * Datepicker component. | ||||||
|  | @ -30,7 +31,7 @@ export function Datepicker({date, onDateSelected, locale, className, ...divProps | ||||||
| 	}, [date]); | 	}, [date]); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className={`datepicker${className ? ` ${className}` : ""}`} {...divProps}> | 		<div className={classes("datepicker", className)} {...divProps}> | ||||||
| 			<div className={"year-month"}> | 			<div className={"year-month"}> | ||||||
| 				{(new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"})).format(date)} | 				{(new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"})).format(date)} | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ import { | ||||||
| } from "@floating-ui/react"; | } from "@floating-ui/react"; | ||||||
| import {UseFloatingOptions} from "@floating-ui/react/dist/floating-ui.react"; | import {UseFloatingOptions} from "@floating-ui/react/dist/floating-ui.react"; | ||||||
| import {mergeRefs} from "react-merge-refs"; | import {mergeRefs} from "react-merge-refs"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Fully managed floating content function. |  * Fully managed floating content function. | ||||||
|  | @ -27,17 +28,27 @@ export type FloatingMode = "always"|"click"|"hover"|"focus"|"managed"; | ||||||
| export type FloatRole = "tooltip" | "dialog" | "alertdialog" | "menu" | "listbox" | "grid" | "tree" | "select" | "label" | "combobox"; | export type FloatRole = "tooltip" | "dialog" | "alertdialog" | "menu" | "listbox" | "grid" | "tree" | "select" | "label" | "combobox"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * A component to show something floating next to an element. |  * Type of the element on which the floating element is based. | ||||||
|  */ |  */ | ||||||
| export function Float({children, content, className, mode, role, floatingOptions}: { | export type FloatChild = (React.ReactElement & React.ClassAttributes<HTMLElement>)|Managed<(React.ReactElement & React.ClassAttributes<HTMLElement>)>; | ||||||
| 	children: (React.ReactElement & React.ClassAttributes<HTMLElement>)|Managed<(React.ReactElement & React.ClassAttributes<HTMLElement>)>; | 
 | ||||||
|  | /** | ||||||
|  |  * Properties of the Float component. | ||||||
|  |  */ | ||||||
|  | export interface FloatProperties | ||||||
|  | { | ||||||
|  | 	children: FloatChild; | ||||||
| 	content?: React.ReactNode|Managed<React.ReactNode>; | 	content?: React.ReactNode|Managed<React.ReactNode>; | ||||||
| 	className?: string; | 	className?: string; | ||||||
| 	mode?: FloatingMode; | 	mode?: FloatingMode; | ||||||
| 	role?: FloatRole; | 	role?: FloatRole; | ||||||
| 	floatingOptions?: UseFloatingOptions; | 	floatingOptions?: UseFloatingOptions; | ||||||
| }): React.ReactElement | } | ||||||
| { | 
 | ||||||
|  | /** | ||||||
|  |  * A component to show something floating next to an element. | ||||||
|  |  */ | ||||||
|  | export const Float = React.forwardRef(({children, content, className, mode, role, floatingOptions}: FloatProperties, ref): React.ReactElement => { | ||||||
| 	// By default, use "always" mode.
 | 	// By default, use "always" mode.
 | ||||||
| 	if (!mode) mode = "always"; | 	if (!mode) mode = "always"; | ||||||
| 
 | 
 | ||||||
|  | @ -101,13 +112,13 @@ export function Float({children, content, className, mode, role, floatingOptions | ||||||
| 			Object.assign( | 			Object.assign( | ||||||
| 				{ | 				{ | ||||||
| 					// Pass references.
 | 					// Pass references.
 | ||||||
| 					ref: mergeRefs([refs.setReference, child?.ref]), | 					ref: mergeRefs([ref, refs.setReference, child?.ref]), | ||||||
| 				}, | 				}, | ||||||
| 				// Get interaction properties.
 | 				// Get interaction properties.
 | ||||||
| 				getReferenceProps(), | 				getReferenceProps(), | ||||||
| 			), | 			), | ||||||
| 		); | 		); | ||||||
| 	}, [children, show, hide, refs.setReference, getReferenceProps]); | 	}, [children, show, hide, ref, refs.setReference, getReferenceProps]); | ||||||
| 
 | 
 | ||||||
| 	// Update floating content.
 | 	// Update floating content.
 | ||||||
| 	const floatingContent = useMemo(() => ( | 	const floatingContent = useMemo(() => ( | ||||||
|  | @ -122,11 +133,11 @@ export function Float({children, content, className, mode, role, floatingOptions | ||||||
| 			{ // Showing floating element if the state says to do so.
 | 			{ // Showing floating element if the state says to do so.
 | ||||||
| 				isMounted && | 				isMounted && | ||||||
| 				<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={"floating"}> | 				<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={"floating"}> | ||||||
| 					<Card style={transitionStyles} className={`floating${className ? ` ${className}` : ""}`}> | 					<Card style={transitionStyles} className={classes("floating", className)}> | ||||||
| 						{floatingContent} | 						{floatingContent} | ||||||
| 					</Card> | 					</Card> | ||||||
| 				</div> | 				</div> | ||||||
| 			} | 			} | ||||||
| 		</> | 		</> | ||||||
| 	); | 	); | ||||||
| } | }) | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import {Check} from "@phosphor-icons/react"; | import {Check} from "@phosphor-icons/react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export function Checkbox({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | export function Checkbox({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | ||||||
| { | { | ||||||
| 	return ( | 	return ( | ||||||
| 		<label className={`box${className ? ` ${className}` : ""}`}> | 		<label className={classes("box", className)}> | ||||||
| 			<input type={"checkbox"} {...inputProps} /> | 			<input type={"checkbox"} {...inputProps} /> | ||||||
| 			<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a> | 			<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a> | ||||||
| 			{children} | 			{children} | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; | import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; | ||||||
| import {formatDate, Modify} from "../../Utils"; | import {classes, formatDate, Modify} from "../../Utils"; | ||||||
| import {Float} from "../Floating/Float"; | import {Float} from "../Floating/Float"; | ||||||
| import {Datepicker} from "../Dates/Datepicker"; | import {Datepicker} from "../Dates/Datepicker"; | ||||||
| 
 | 
 | ||||||
|  | @ -101,7 +101,7 @@ export function DatepickerInput( | ||||||
| 	}, [dateText, onChange]); | 	}, [dateText, onChange]); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<label className={`datepicker-input${invalidDate ? " error" : ""}${className ? ` ${className}` : ""}`} | 		<label className={classes("datepicker-input", invalidDate ? "error" : null, className)} | ||||||
| 			     // Keeping focus on the input when something else in the label takes it.
 | 			     // Keeping focus on the input when something else in the label takes it.
 | ||||||
| 			     onFocus={useCallback(() => inputRef.current?.focus(), [inputRef])}> | 			     onFocus={useCallback(() => inputRef.current?.focus(), [inputRef])}> | ||||||
| 			{children} | 			{children} | ||||||
|  |  | ||||||
|  | @ -1,12 +1,13 @@ | ||||||
| import React, {useState} from "react"; | import React, {useState} from "react"; | ||||||
| import {Eye, EyeSlash} from "@phosphor-icons/react"; | import {Eye, EyeSlash} from "@phosphor-icons/react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export function PasswordInput({children, className, type, ...props}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | export function PasswordInput({children, className, type, ...props}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | ||||||
| { | { | ||||||
| 	const [showPassword, setShowPassword] = useState(false); | 	const [showPassword, setShowPassword] = useState(false); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<label className={`password${className ? ` ${className}` : ""}`}> | 		<label className={classes("password", className)}> | ||||||
| 			{children} | 			{children} | ||||||
| 			<div> | 			<div> | ||||||
| 				<input type={showPassword ? "text" : "password"} {...props} /> | 				<input type={showPassword ? "text" : "password"} {...props} /> | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import {Check} from "@phosphor-icons/react"; | import {Check} from "@phosphor-icons/react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export function Radio({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | export function Radio({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement | ||||||
| { | { | ||||||
| 	return ( | 	return ( | ||||||
| 		<label className={`box${className ? ` ${className}` : ""}`}> | 		<label className={classes("box", className)}> | ||||||
| 			<input type={"radio"} {...inputProps} /> | 			<input type={"radio"} {...inputProps} /> | ||||||
| 			<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a> | 			<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a> | ||||||
| 			{children} | 			{children} | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import React from "react"; | import React from "react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export function RequiredField({className, ...props}: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement | export function RequiredField({className, ...props}: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement | ||||||
| { | { | ||||||
| 	return <span className={`required${className ? ` ${className}` : ""}`} {...props}></span>; | 	return <span className={classes("required", className)} {...props}></span>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import React, {useCallback, useEffect, useMemo, useState} from "react"; | import React, {useCallback, useEffect, useMemo, useState} from "react"; | ||||||
| import {formatTime, Modify} from "../../Utils"; | import {classes, formatTime, Modify} from "../../Utils"; | ||||||
| 
 | 
 | ||||||
| export function TimepickerInput( | export function TimepickerInput( | ||||||
| 	{ | 	{ | ||||||
|  | @ -73,7 +73,7 @@ export function TimepickerInput( | ||||||
| 	}, [timeText, timeValue, onChange]); | 	}, [timeText, timeValue, onChange]); | ||||||
| 
 | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<label className={`timepicker${className ? ` ${className}` : ""}`}> | 		<label className={classes("timepicker", className)}> | ||||||
| 			{children} | 			{children} | ||||||
| 
 | 
 | ||||||
| 			<input type={"text"} placeholder={"HH:MM"} value={timeText} | 			<input type={"text"} placeholder={"HH:MM"} value={timeText} | ||||||
|  |  | ||||||
							
								
								
									
										132
									
								
								src/Components/Select/OptionsSuggestions.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/Components/Select/OptionsSuggestions.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,132 @@ | ||||||
|  | import React, {MutableRefObject, useCallback, useMemo, useRef, useState} from "react"; | ||||||
|  | import {classes} from "../../Utils"; | ||||||
|  | import {Check} from "@phosphor-icons/react"; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Suggestions preselected options navigation configuration. | ||||||
|  |  */ | ||||||
|  | export interface SuggestionsNavigation | ||||||
|  | { | ||||||
|  | 	/** | ||||||
|  | 	 * Return true if the preselected options navigation is initialized. | ||||||
|  | 	 */ | ||||||
|  | 	initialized(): boolean; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Preselect the next option in the suggestions. | ||||||
|  | 	 */ | ||||||
|  | 	next(): void; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Preselect the previous option in the suggestions. | ||||||
|  | 	 */ | ||||||
|  | 	previous(): void; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Select the currently preselected option. | ||||||
|  | 	 */ | ||||||
|  | 	select(): void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Hook to get the preselected options navigation reference. | ||||||
|  |  */ | ||||||
|  | export function useSuggestionsNavigation(): MutableRefObject<SuggestionsNavigation> | ||||||
|  | { | ||||||
|  | 	return useRef({ | ||||||
|  | 		initialized(): boolean | ||||||
|  | 		{ | ||||||
|  | 			return false; | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		next(): void | ||||||
|  | 		{ | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		previous(): void | ||||||
|  | 		{ | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		select(): void | ||||||
|  | 		{ | ||||||
|  | 		}, | ||||||
|  | 	} as SuggestionsNavigation); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function OptionsSuggestions<OptionKey extends keyof any, Option>({options, onSelected, selectedOptions, renderOption, navigator}: { | ||||||
|  | 	/** | ||||||
|  | 	 * Options to suggest. | ||||||
|  | 	 */ | ||||||
|  | 	options: Record<OptionKey, Option>; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Called when an option is selected. | ||||||
|  | 	 * @param key | ||||||
|  | 	 * @param option | ||||||
|  | 	 */ | ||||||
|  | 	onSelected: (key: OptionKey, option: Option) => void; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Already selected options that will be shown differently. | ||||||
|  | 	 */ | ||||||
|  | 	selectedOptions?: Record<OptionKey, Option>; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Render an option. | ||||||
|  | 	 * @param option The option to render. | ||||||
|  | 	 */ | ||||||
|  | 	renderOption?: (option: Option) => React.ReactNode; | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * A reference to a preselected suggestions options navigation object. | ||||||
|  | 	 */ | ||||||
|  | 	navigator?: MutableRefObject<SuggestionsNavigation>; | ||||||
|  | }): React.ReactNode | ||||||
|  | { | ||||||
|  | 	// Initialize default option render function.
 | ||||||
|  | 	const defaultRenderOption = useCallback((option: Option) => (String(option)), []); | ||||||
|  | 
 | ||||||
|  | 	const optionsArray = useMemo(() => (Object.entries(options) as [OptionKey, Option][]), [options]); | ||||||
|  | 
 | ||||||
|  | 	const [preselectedOptionIndex, setPreselectedOptionIndex] = useState<number>(0); | ||||||
|  | 
 | ||||||
|  | 	const navigation = useMemo(() => ({ | ||||||
|  | 		initialized(): boolean | ||||||
|  | 		{ | ||||||
|  | 			return true; | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		next(): void | ||||||
|  | 		{ | ||||||
|  | 			// Preselect the next option in the options array, or the first one if it's the last element.
 | ||||||
|  | 			setPreselectedOptionIndex((optionsArray.length == (preselectedOptionIndex + 1)) ? 0 : (preselectedOptionIndex + 1)); | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		previous(): void | ||||||
|  | 		{ | ||||||
|  | 			// Preselect the previous option in the options array, or the last one if it's the first element.
 | ||||||
|  | 			setPreselectedOptionIndex(((preselectedOptionIndex - 1) < 0) ? (optionsArray.length - 1) : (preselectedOptionIndex - 1)); | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		select(): void | ||||||
|  | 		{ | ||||||
|  | 			// Get the currently preselected option.
 | ||||||
|  | 			const [optionKey, option] = optionsArray[preselectedOptionIndex]; | ||||||
|  | 			// Select the currently preselected option.
 | ||||||
|  | 			onSelected(optionKey, option); | ||||||
|  | 		}, | ||||||
|  | 	}), [optionsArray, preselectedOptionIndex, setPreselectedOptionIndex]); | ||||||
|  | 
 | ||||||
|  | 	if (navigator) | ||||||
|  | 		// If navigator reference is set, assigning it.
 | ||||||
|  | 		navigator.current = navigation; | ||||||
|  | 
 | ||||||
|  | 	return optionsArray.map(([key, option], index) => ( | ||||||
|  | 		<a key={String(key)} className={classes("suggestion", preselectedOptionIndex == index ? "preselected" : null, selectedOptions?.[key] ? "selected" : null)} | ||||||
|  | 		   onClick={() => { onSelected(key, option); }}> | ||||||
|  | 			{(renderOption ?? defaultRenderOption)(option)} | ||||||
|  | 
 | ||||||
|  | 			<span className={"selected"}><Check weight={"bold"} /></span> | ||||||
|  | 		</a> | ||||||
|  | 	)); | ||||||
|  | } | ||||||
							
								
								
									
										230
									
								
								src/Components/Select/Select.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								src/Components/Select/Select.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,230 @@ | ||||||
|  | import React, {useCallback, useMemo, useRef} from "react"; | ||||||
|  | import {Suggestible} from "./Suggestible"; | ||||||
|  | import {OptionsSuggestions, useSuggestionsNavigation} from "./OptionsSuggestions"; | ||||||
|  | import {classes, Modify, normalizeString} from "../../Utils"; | ||||||
|  | import {CaretDown, Check, X} from "@phosphor-icons/react"; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Generic select component. | ||||||
|  |  */ | ||||||
|  | export function Select<OptionKey extends keyof any, Option>( | ||||||
|  | 	{ | ||||||
|  | 		className, | ||||||
|  | 		value, onChange, | ||||||
|  | 		options, renderOption, match, | ||||||
|  | 		selectibleMaxCount, multiple, | ||||||
|  | 		blurOnSelect, blurWhenMaxCountSelected, | ||||||
|  | 		// Properties to pass down.
 | ||||||
|  | 		onKeyDown, | ||||||
|  | 		// Already set properties.
 | ||||||
|  | 		type, role, | ||||||
|  | 		children, | ||||||
|  | 		...props | ||||||
|  | 	}: React.PropsWithChildren<Modify<React.InputHTMLAttributes<HTMLInputElement>, { | ||||||
|  | 		/** | ||||||
|  | 		 * The currently selected option(s). | ||||||
|  | 		 */ | ||||||
|  | 		value: OptionKey|OptionKey[]; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Called when new options are selected. | ||||||
|  | 		 * @param newValue | ||||||
|  | 		 */ | ||||||
|  | 		onChange: (newValue: OptionKey|OptionKey[]) => void; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Options list or a way to get it from a given search. | ||||||
|  | 		 */ | ||||||
|  | 		options: Record<OptionKey, Option>; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Render an option. | ||||||
|  | 		 * @param option The option to render. | ||||||
|  | 		 */ | ||||||
|  | 		renderOption?: (option: Option) => React.ReactNode; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Option match function. Return true if the given option matches the given search query. | ||||||
|  | 		 * @param search The search query. | ||||||
|  | 		 * @param option The option that should match the search query. | ||||||
|  | 		 */ | ||||||
|  | 		match?: (search: string, option: Option) => boolean; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * Max count of options to allow to select. | ||||||
|  | 		 * 1 by default when multiple is false, infinity when multiple is true. | ||||||
|  | 		 */ | ||||||
|  | 		selectibleMaxCount?: number; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * True to allow to select an infinite count of options. | ||||||
|  | 		 * false by default. | ||||||
|  | 		 */ | ||||||
|  | 		multiple?: boolean; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * When true, the select loses focus when a new option is selected (by clicking or pressing Enter). | ||||||
|  | 		 * false by default. | ||||||
|  | 		 */ | ||||||
|  | 		blurOnSelect?: boolean; | ||||||
|  | 
 | ||||||
|  | 		/** | ||||||
|  | 		 * When true, the select loses focus when the max count of selectible options have been reached (by clicking or pressing Enter). | ||||||
|  | 		 * true by default. | ||||||
|  | 		 */ | ||||||
|  | 		blurWhenMaxCountSelected?: boolean; | ||||||
|  | 
 | ||||||
|  | 		// Already set properties.
 | ||||||
|  | 		type?: never; | ||||||
|  | 		role?: never; | ||||||
|  | 	}>>): React.ReactElement | ||||||
|  | { | ||||||
|  | 	const [search, setSearch] = React.useState(""); | ||||||
|  | 
 | ||||||
|  | 	// By default, allow to select only one option.
 | ||||||
|  | 	// If `multiple` is set and `selectibleMaxCount` is not, allow an infinite count of options to select.
 | ||||||
|  | 	selectibleMaxCount = selectibleMaxCount ?? ((multiple === undefined ? false : multiple) ? Infinity : 1); | ||||||
|  | 
 | ||||||
|  | 	// true by default.
 | ||||||
|  | 	blurOnSelect = blurOnSelect === undefined ? false : blurOnSelect; | ||||||
|  | 	blurWhenMaxCountSelected = blurWhenMaxCountSelected === undefined ? true : blurWhenMaxCountSelected; | ||||||
|  | 
 | ||||||
|  | 	// Initialize default option render function.
 | ||||||
|  | 	const defaultRenderOption = useCallback((option: Option) => (String(option)), []); | ||||||
|  | 
 | ||||||
|  | 	// Initialize default match option function.
 | ||||||
|  | 	const defaultMatchOption = useCallback((search: string, option: Option) => normalizeString(String(option)).includes(normalizeString(search)), []); | ||||||
|  | 
 | ||||||
|  | 	// An array of the selected options.
 | ||||||
|  | 	const selectedOptions = useMemo(() => ( | ||||||
|  | 		// Normalize value to an array.
 | ||||||
|  | 		((!Array.isArray(value) ? [value] : value) | ||||||
|  | 			// Try to get the corresponding option for each given value.
 | ||||||
|  | 			.map((optionKey) => [optionKey, options?.[optionKey]]) as [OptionKey, Option][]) | ||||||
|  | 			// Filter non-existing options.
 | ||||||
|  | 			.filter(([_, option]) => (option !== undefined)) | ||||||
|  | 	), [value, options]); | ||||||
|  | 
 | ||||||
|  | 	// A reference to the main search input.
 | ||||||
|  | 	const inputRef = useRef<HTMLInputElement>(); | ||||||
|  | 
 | ||||||
|  | 	// The suggestions' navigator.
 | ||||||
|  | 	const suggestionsNavigator = useSuggestionsNavigation(); | ||||||
|  | 
 | ||||||
|  | 	// Get all available options, filtered using search query.
 | ||||||
|  | 	const filteredOptions = useMemo(() => ( | ||||||
|  | 		!search || (search.length == 0) | ||||||
|  | 			// Nothing is searched, return all options.
 | ||||||
|  | 			? options | ||||||
|  | 			// Filter options using search query and matching function.
 | ||||||
|  | 			: Object.fromEntries( | ||||||
|  | 				(Object.entries(options) as [OptionKey, Option][]).filter(([optionKey, option]) => ( | ||||||
|  | 					(match ?? defaultMatchOption)(search, option) | ||||||
|  | 				)) | ||||||
|  | 			) as Record<OptionKey, Option> | ||||||
|  | 	), [options, search, match, defaultMatchOption]); | ||||||
|  | 
 | ||||||
|  | 	// Called when a new option is selected.
 | ||||||
|  | 	const handleSelectedOption = useCallback((selectedOption: OptionKey) => { | ||||||
|  | 		// Get an associative object from selected options.
 | ||||||
|  | 		const currentSelection = Object.fromEntries(selectedOptions) as Record<OptionKey, Option>; | ||||||
|  | 
 | ||||||
|  | 		// Initialize the new selection variable.
 | ||||||
|  | 		let newSelection: OptionKey|OptionKey[] = Object.keys(currentSelection) as OptionKey[]; | ||||||
|  | 
 | ||||||
|  | 		if (selectibleMaxCount == 1) | ||||||
|  | 			// Only one possible selection = the newly selected option is the new selection.
 | ||||||
|  | 			newSelection = selectedOption; | ||||||
|  | 		else | ||||||
|  | 		{ // Multiple selections possible.
 | ||||||
|  | 			if (!currentSelection[selectedOption]) | ||||||
|  | 			{ // The newly selected option wasn't selected, we should add it in the selection array.
 | ||||||
|  | 				// Add the newly selected option in the array.
 | ||||||
|  | 				newSelection = [...newSelection, selectedOption]; | ||||||
|  | 				if (newSelection.length > selectibleMaxCount) | ||||||
|  | 					// If the array is now too big, we remove the first options to match the max count of options.
 | ||||||
|  | 					newSelection.splice(0, newSelection.length - selectibleMaxCount); | ||||||
|  | 			} | ||||||
|  | 			else | ||||||
|  | 			{ // The option was already selected, we should deselect it.
 | ||||||
|  | 				newSelection = newSelection.filter((key) => key != selectedOption); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Call onChange event with the new selection.
 | ||||||
|  | 		onChange(newSelection); | ||||||
|  | 
 | ||||||
|  | 		// Reset search query.
 | ||||||
|  | 		setSearch(""); | ||||||
|  | 
 | ||||||
|  | 		if ( | ||||||
|  | 			// Always blur on selection.
 | ||||||
|  | 			blurOnSelect | ||||||
|  | 			|| | ||||||
|  | 			// Blur when the new selection now reach the max selectible count.
 | ||||||
|  | 			(blurWhenMaxCountSelected && ((selectibleMaxCount == 1 && !!newSelection) || (selectibleMaxCount == (newSelection as OptionKey[])?.length))) | ||||||
|  | 		) // Try to lose focus, as a new selection has been made and blur conditions are reached.
 | ||||||
|  | 			window.setTimeout(() => { inputRef?.current?.blur(); }, 0); | ||||||
|  | 	}, [selectedOptions, onChange, setSearch]); | ||||||
|  | 
 | ||||||
|  | 	// Called when an option is deselected.
 | ||||||
|  | 	const handleDeselectedOption = useCallback((deselectedOption: OptionKey) => { | ||||||
|  | 		// Call onChange event with the new selection.
 | ||||||
|  | 		onChange( | ||||||
|  | 			selectedOptions | ||||||
|  | 				// Remove deselected option if it was in the selected options array.
 | ||||||
|  | 				.filter(([optionKey]) => (optionKey != deselectedOption)) | ||||||
|  | 				.map(([optionKey]) => optionKey) | ||||||
|  | 		); | ||||||
|  | 	}, [selectedOptions, onChange]); | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<label className={classes("select", className)} | ||||||
|  | 		       onFocus={useCallback(() => { | ||||||
|  | 			       inputRef.current?.focus(); | ||||||
|  | 		       }, [inputRef])}> | ||||||
|  | 			<div> | ||||||
|  | 				<Suggestible suggestions={ | ||||||
|  | 					<OptionsSuggestions options={filteredOptions} renderOption={renderOption} | ||||||
|  | 						                  // Get an associative object from selected options.
 | ||||||
|  | 					                    selectedOptions={Object.fromEntries(selectedOptions) as Record<OptionKey, Option>} | ||||||
|  | 					                    onSelected={handleSelectedOption} navigator={suggestionsNavigator}/> | ||||||
|  | 				}> | ||||||
|  | 					<input ref={inputRef} type={"text"} role={"select"} value={search} | ||||||
|  | 					       onKeyDown={(event) => { | ||||||
|  | 						       if (event.key == "ArrowDown") | ||||||
|  | 							       suggestionsNavigator.current?.next(); | ||||||
|  | 						       else if (event.key == "ArrowUp") | ||||||
|  | 							       suggestionsNavigator.current?.previous(); | ||||||
|  | 						       else if (event.key == "Enter") | ||||||
|  | 							       suggestionsNavigator.current?.select(); | ||||||
|  | 
 | ||||||
|  | 						       return onKeyDown?.(event); | ||||||
|  | 					       }} | ||||||
|  | 					       onChange={(event) => setSearch(event.currentTarget.value)} | ||||||
|  | 					       {...props} /> | ||||||
|  | 				</Suggestible> | ||||||
|  | 
 | ||||||
|  | 				<a className={"button"} tabIndex={-1}><CaretDown weight={"bold"}/></a> | ||||||
|  | 			</div> | ||||||
|  | 
 | ||||||
|  | 			<ul className={"selected"}> | ||||||
|  | 				{ // Showing each selected value.
 | ||||||
|  | 					selectedOptions.map(([optionKey, option]) => ( | ||||||
|  | 						<li key={String(optionKey)}> | ||||||
|  | 							<Check weight={"bold"}/> | ||||||
|  | 							<div className={"option"}>{(renderOption ?? defaultRenderOption)(option)}</div> | ||||||
|  | 							<button className={"remove flat"} type={"button"} onClick={() => handleDeselectedOption(optionKey)}> | ||||||
|  | 								<X weight={"bold"}/> | ||||||
|  | 							</button> | ||||||
|  | 						</li> | ||||||
|  | 					)) | ||||||
|  | 				} | ||||||
|  | 			</ul> | ||||||
|  | 
 | ||||||
|  | 			<div className={"label"}> | ||||||
|  | 				{children} | ||||||
|  | 			</div> | ||||||
|  | 		</label> | ||||||
|  | 	); | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								src/Components/Select/SimpleSuggestions.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/Components/Select/SimpleSuggestions.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | import React from "react"; | ||||||
|  | 
 | ||||||
|  | export function SimpleSuggestions(): React.ReactElement | ||||||
|  | { | ||||||
|  | 	return ( | ||||||
|  | 		<> | ||||||
|  | 			<a className={"suggestion"}>test</a> | ||||||
|  | 			<a className={"suggestion"}>another</a> | ||||||
|  | 		</> | ||||||
|  | 	); | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/Components/Select/Suggestible.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/Components/Select/Suggestible.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | import React from "react"; | ||||||
|  | import {Float, FloatProperties} from "../Floating/Float"; | ||||||
|  | import {classes, Modify} from "../../Utils"; | ||||||
|  | 
 | ||||||
|  | export function Suggestible({className, suggestions, mode, content, role, children, ...props}: Modify<FloatProperties, { | ||||||
|  | 	/** | ||||||
|  | 	 * Suggestions element. | ||||||
|  | 	 */ | ||||||
|  | 	suggestions: React.ReactNode; | ||||||
|  | 
 | ||||||
|  | 	content?: never; | ||||||
|  | 	role?: never; | ||||||
|  | }>) | ||||||
|  | { | ||||||
|  | 	// Default mode for showing suggestions is "focus".
 | ||||||
|  | 	mode = mode ?? "focus"; | ||||||
|  | 
 | ||||||
|  | 	return ( | ||||||
|  | 		<Float className={classes("suggestions", className)} role={"select"} content={suggestions} mode={mode} {...props}> | ||||||
|  | 			{children} | ||||||
|  | 		</Float> | ||||||
|  | 	); | ||||||
|  | } | ||||||
|  | @ -1,6 +1,15 @@ | ||||||
| 
 | 
 | ||||||
| export type Modify<T, R> = Omit<T, keyof R> & R; | export type Modify<T, R> = Omit<T, keyof R> & R; | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Merge multiple class names to one full class name. | ||||||
|  |  * @param className Class names. | ||||||
|  |  */ | ||||||
|  | export function classes(...className: (string|null|undefined|false)[]): string | ||||||
|  | { | ||||||
|  | 	return className.filter((className) => !!className).join(" "); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function formatDate(date: Date): string | export function formatDate(date: Date): string | ||||||
| { | { | ||||||
| 	return ((date.getDate() < 10 ? "0" : "") + date.getDate()) + "/" + (((date.getMonth() + 1) < 10 ? "0" : "") + (date.getMonth() + 1)) + "/" + date.getFullYear(); | 	return ((date.getDate() < 10 ? "0" : "") + date.getDate()) + "/" + (((date.getMonth() + 1) < 10 ? "0" : "") + (date.getMonth() + 1)) + "/" + date.getFullYear(); | ||||||
|  | @ -10,3 +19,15 @@ export function formatTime(date: Date): string | ||||||
| { | { | ||||||
| 	return (date.getHours() < 10 ? "0" : "") + date.getHours() + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes(); | 	return (date.getHours() < 10 ? "0" : "") + date.getHours() + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes(); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Normalize a given string for searching. | ||||||
|  |  * @param str The string to normalize. | ||||||
|  |  */ | ||||||
|  | export function normalizeString(str: string): string | ||||||
|  | { | ||||||
|  | 	return str | ||||||
|  | 		? str.toLowerCase?.().normalize?.("NFD") | ||||||
|  | 			.replace?.(/[\u0300-\u036f]/g, "") | ||||||
|  | 		: ""; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -21,42 +21,60 @@ | ||||||
| 	@green-lighter: #22BD12; --green-lighter: @green-lighter; | 	@green-lighter: #22BD12; --green-lighter: @green-lighter; | ||||||
| 	@green: #1DA90F; --green: @green; | 	@green: #1DA90F; --green: @green; | ||||||
| 	@green-darker: #159308; --green-darker: @green-darker; | 	@green-darker: #159308; --green-darker: @green-darker; | ||||||
|  | 	@green-background: #A9FFA0; --green-background: @green-background; | ||||||
|  | 	@green-background-darker: #86F37E; --green-background-darker: @green-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@red-lighter: #E32424; --red-lighter: @red-lighter; | 	@red-lighter: #E32424; --red-lighter: @red-lighter; | ||||||
| 	@red: #D01212; --red: @red; | 	@red: #D01212; --red: @red; | ||||||
| 	@red-darker: #AF0707; --red-darker: @red-darker; | 	@red-darker: #AF0707; --red-darker: @red-darker; | ||||||
|  | 	@red-background: #FFBABA; --red-background: @red-background; | ||||||
|  | 	@red-background-darker: #FFA6A6; --red-background-darker: @red-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@blue-lighter: #378AFF; --blue-lighter: @blue-lighter; | 	@blue-lighter: #378AFF; --blue-lighter: @blue-lighter; | ||||||
| 	@blue: #0D6DEE; --blue: @blue; | 	@blue: #0D6DEE; --blue: @blue; | ||||||
| 	@blue-darker: #0657C5; --blue-darker: @blue-darker; | 	@blue-darker: #0657C5; --blue-darker: @blue-darker; | ||||||
|  | 	@blue-background: #9DC8FF; --blue-background: @blue-background; | ||||||
|  | 	@blue-background-darker: #7EA9E1; --blue-background-darker: @blue-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@orange-lighter: #E77220; --orange-lighter: @orange-lighter; | 	@orange-lighter: #E77220; --orange-lighter: @orange-lighter; | ||||||
| 	@orange: #D06112; --orange: @orange; | 	@orange: #D06112; --orange: @orange; | ||||||
| 	@orange-darker: #BB5308; --orange-darker: @orange-darker; | 	@orange-darker: #BB5308; --orange-darker: @orange-darker; | ||||||
|  | 	@orange-background: #FFC599; --orange-background: @orange-background; | ||||||
|  | 	@orange-background-darker: #FFB47D; --orange-background-darker: @orange-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@pink-lighter: #EF56DF; --pink-lighter: @pink-lighter; | 	@pink-lighter: #EF56DF; --pink-lighter: @pink-lighter; | ||||||
| 	@pink: #CE3EBF; --pink: @pink; | 	@pink: #CE3EBF; --pink: @pink; | ||||||
| 	@pink-darker: #B927AB; --pink-darker: @pink-darker; | 	@pink-darker: #B927AB; --pink-darker: @pink-darker; | ||||||
|  | 	@pink-background: #FFABF4; --pink-background: @pink-background; | ||||||
|  | 	@pink-background-darker: #EF8CE1; --pink-background-darker: @pink-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@purple-lighter: #9752FF; --purple-lighter: @purple-lighter; | 	@purple-lighter: #9752FF; --purple-lighter: @purple-lighter; | ||||||
| 	@purple: #7D2AFF; --purple: @purple; | 	@purple: #7D2AFF; --purple: @purple; | ||||||
| 	@purple-darker: #6610EE; --purple-darker: @purple-darker; | 	@purple-darker: #6610EE; --purple-darker: @purple-darker; | ||||||
|  | 	@purple-background: #C8A5FF; --purple-background: @purple-background; | ||||||
|  | 	@purple-background-darker: #B489F6; --purple-background-darker: @purple-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@yellow-lighter: #F8DF3D; --yellow-lighter: @yellow-lighter; | 	@yellow-lighter: #F8DF3D; --yellow-lighter: @yellow-lighter; | ||||||
| 	@yellow: #EACD0D; --yellow: @yellow; | 	@yellow: #EACD0D; --yellow: @yellow; | ||||||
| 	@yellow-darker: #D3B803; --yellow-darker: @yellow-darker; | 	@yellow-darker: #D3B803; --yellow-darker: @yellow-darker; | ||||||
|  | 	@yellow-background: #FFF195; --yellow-background: @yellow-background; | ||||||
|  | 	@yellow-background-darker: #ECDB71; --yellow-background-darker: @yellow-background-darker; | ||||||
| 
 | 
 | ||||||
| 	@brown-lighter: #5E2617; --brown-lighter: @brown-lighter; | 	@brown-lighter: #5E2617; --brown-lighter: @brown-lighter; | ||||||
| 	@brown: #4B190C; --brown: @brown; | 	@brown: #4B190C; --brown: @brown; | ||||||
| 	@brown-darker: #3B1105; --brown-darker: @brown-darker; | 	@brown-darker: #3B1105; --brown-darker: @brown-darker; | ||||||
|  | 	@brown-background: #B6968F; --brown-background: @brown-background; | ||||||
|  | 	@brown-background-darker: #A97E74; --brown-background-darker: @brown-background-darker; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 	@primary-lighter: @blue-lighter; --primary-lighter: @primary-lighter; | 	@primary-lighter: @blue-lighter; --primary-lighter: @primary-lighter; | ||||||
| 	@primary: @blue; --primary: @primary; | 	@primary: @blue; --primary: @primary; | ||||||
| 	@primary-darker: @blue-darker; --primary-darker: @primary-darker; | 	@primary-darker: @blue-darker; --primary-darker: @primary-darker; | ||||||
|  | 	@primary-background: @blue-background; --primary-background: @primary-background; | ||||||
| 
 | 
 | ||||||
| 	@secondary-lighter: @orange-lighter; --secondary-lighter: @secondary-lighter; | 	@secondary-lighter: @orange-lighter; --secondary-lighter: @secondary-lighter; | ||||||
| 	@secondary: @orange; --secondary: @secondary; | 	@secondary: @orange; --secondary: @secondary; | ||||||
| 	@secondary-darker: @orange-darker; --secondary-darker: @secondary-darker; | 	@secondary-darker: @orange-darker; --secondary-darker: @secondary-darker; | ||||||
|  | 	@secondary-background: @orange-background; --secondary-background: @secondary-background; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,10 +2,11 @@ | ||||||
| @import "components/_button"; | @import "components/_button"; | ||||||
| @import "components/_card"; | @import "components/_card"; | ||||||
| @import "components/_dates"; | @import "components/_dates"; | ||||||
|  | @import "components/_floating"; | ||||||
| @import "components/_form"; | @import "components/_form"; | ||||||
| @import "components/_headings"; | @import "components/_headings"; | ||||||
| @import "components/_link"; | @import "components/_link"; | ||||||
| @import "components/_list"; | @import "components/_list"; | ||||||
| @import "components/_floating"; | @import "components/_select"; | ||||||
| @import "components/_steps"; | @import "components/_steps"; | ||||||
| @import "components/_table"; | @import "components/_table"; | ||||||
|  |  | ||||||
|  | @ -71,6 +71,22 @@ a.button, button, input[type="submit"], input[type="reset"] | ||||||
| 		{ | 		{ | ||||||
| 			outline-color: var(--green); | 			outline-color: var(--green); | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		&.flat | ||||||
|  | 		{ | ||||||
|  | 			border-color: var(--green-background); | ||||||
|  | 			background: var(--green-background); | ||||||
|  | 			color: var(--green); | ||||||
|  | 
 | ||||||
|  | 			&:hover | ||||||
|  | 			{ | ||||||
|  | 				background: var(--green-background-darker); | ||||||
|  | 			} | ||||||
|  | 			&:focus | ||||||
|  | 			{ | ||||||
|  | 				outline-color: var(--green-background-darker); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	&.orange, &.cancel, &.back, &.return | 	&.orange, &.cancel, &.back, &.return | ||||||
|  | @ -86,9 +102,25 @@ a.button, button, input[type="submit"], input[type="reset"] | ||||||
| 		{ | 		{ | ||||||
| 			outline-color: var(--orange); | 			outline-color: var(--orange); | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		&.flat | ||||||
|  | 		{ | ||||||
|  | 			border-color: var(--orange-background); | ||||||
|  | 			background: var(--orange-background); | ||||||
|  | 			color: var(--orange); | ||||||
|  | 
 | ||||||
|  | 			&:hover | ||||||
|  | 			{ | ||||||
|  | 				background: var(--orange-background-darker); | ||||||
|  | 			} | ||||||
|  | 			&:focus | ||||||
|  | 			{ | ||||||
|  | 				outline-color: var(--orange-background-darker); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	&.red, &.delete, &.no, &.negative, &.bad | 	&.red, &.delete, &.remove, &.no, &.negative, &.bad | ||||||
| 	{ | 	{ | ||||||
| 		border-color: var(--red-darker); | 		border-color: var(--red-darker); | ||||||
| 		background: var(--red); | 		background: var(--red); | ||||||
|  | @ -101,6 +133,22 @@ a.button, button, input[type="submit"], input[type="reset"] | ||||||
| 		{ | 		{ | ||||||
| 			outline-color: var(--red); | 			outline-color: var(--red); | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		&.flat | ||||||
|  | 		{ | ||||||
|  | 			border-color: var(--red-background); | ||||||
|  | 			background: var(--red-background); | ||||||
|  | 			color: var(--red); | ||||||
|  | 
 | ||||||
|  | 			&:hover | ||||||
|  | 			{ | ||||||
|  | 				background: var(--red-background-darker); | ||||||
|  | 			} | ||||||
|  | 			&:focus | ||||||
|  | 			{ | ||||||
|  | 				outline-color: var(--red-background-darker); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	svg | 	svg | ||||||
|  | @ -110,4 +158,8 @@ a.button, button, input[type="submit"], input[type="reset"] | ||||||
| 		margin-right: 0.2em; | 		margin-right: 0.2em; | ||||||
| 		vertical-align: middle; | 		vertical-align: middle; | ||||||
| 	} | 	} | ||||||
|  | 	> svg:last-of-type | ||||||
|  | 	{ | ||||||
|  | 		margin-right: 0.05em; | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										3
									
								
								src/styles/components/_select.less
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/styles/components/_select.less
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | ||||||
|  | @import "select/_input"; | ||||||
|  | @import "select/_selected"; | ||||||
|  | @import "select/_suggestions"; | ||||||
|  | @ -3,6 +3,7 @@ input, textarea, select | ||||||
| 	transition: outline 0.2s ease; | 	transition: outline 0.2s ease; | ||||||
| 	display: block; | 	display: block; | ||||||
| 
 | 
 | ||||||
|  | 	width: 15em; | ||||||
| 	padding: 0.5em; | 	padding: 0.5em; | ||||||
| 	border-radius: 0.25em; | 	border-radius: 0.25em; | ||||||
| 	box-sizing: border-box; | 	box-sizing: border-box; | ||||||
|  |  | ||||||
|  | @ -28,4 +28,10 @@ label | ||||||
| 			color: var(--red); | 			color: var(--red); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	span.description | ||||||
|  | 	{ | ||||||
|  | 		color: var(--foreground-lightest); | ||||||
|  | 		font-size: 0.9em; | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -7,7 +7,6 @@ label.password | ||||||
| 		> input | 		> input | ||||||
| 		{ | 		{ | ||||||
| 			padding-right: 2.5em; | 			padding-right: 2.5em; | ||||||
| 			padding-right: 2.5em; |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		a.button | 		a.button | ||||||
|  |  | ||||||
							
								
								
									
										67
									
								
								src/styles/components/select/_input.less
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/styles/components/select/_input.less
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,67 @@ | ||||||
|  | label.select | ||||||
|  | { | ||||||
|  | 	display: flex; | ||||||
|  | 	flex-direction: column-reverse; | ||||||
|  | 	align-items: stretch; | ||||||
|  | 
 | ||||||
|  | 	> .label | ||||||
|  | 	{ | ||||||
|  | 		margin: 0 0.5em; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	> div | ||||||
|  | 	{ | ||||||
|  | 		position: relative; | ||||||
|  | 
 | ||||||
|  | 		> input | ||||||
|  | 		{ | ||||||
|  | 			margin: auto; | ||||||
|  | 			padding-right: 2.5em; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		> a.button | ||||||
|  | 		{ | ||||||
|  | 			transition: transform 0.2s ease, background 0.2s ease; | ||||||
|  | 
 | ||||||
|  | 			position: absolute; | ||||||
|  | 			top: 0; | ||||||
|  | 			bottom: 0; | ||||||
|  | 			right: 0.35em; | ||||||
|  | 
 | ||||||
|  | 			display: flex; | ||||||
|  | 			align-items: center; | ||||||
|  | 			justify-content: center; | ||||||
|  | 
 | ||||||
|  | 			margin: auto; | ||||||
|  | 			padding: 0; | ||||||
|  | 			width: 1.75em; | ||||||
|  | 			height: 1.75em; | ||||||
|  | 
 | ||||||
|  | 			box-shadow: 0 0 0 0 transparent; | ||||||
|  | 			border: none; | ||||||
|  | 			background: transparent; | ||||||
|  | 			color: var(--foreground-lightest); | ||||||
|  | 
 | ||||||
|  | 			transform: rotate(0deg); | ||||||
|  | 			transform-origin: center; | ||||||
|  | 
 | ||||||
|  | 			&:hover | ||||||
|  | 			{ | ||||||
|  | 				background: var(--background-darker); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			> svg | ||||||
|  | 			{ margin: 0; } | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		&:focus-within | ||||||
|  | 		{ | ||||||
|  | 			> a.button | ||||||
|  | 			{ | ||||||
|  | 				transform: rotate(180deg); | ||||||
|  | 
 | ||||||
|  | 				&:hover { background: transparent; } | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										45
									
								
								src/styles/components/select/_selected.less
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/styles/components/select/_selected.less
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | label.select ul.selected | ||||||
|  | { | ||||||
|  | 	display: flex; | ||||||
|  | 	flex-direction: column; | ||||||
|  | 	gap: 0.25em; | ||||||
|  | 
 | ||||||
|  | 	margin: 0.25em 0; | ||||||
|  | 	padding: 0; | ||||||
|  | 	list-style: none; | ||||||
|  | 
 | ||||||
|  | 	> li | ||||||
|  | 	{ | ||||||
|  | 		display: flex; | ||||||
|  | 		align-items: center; | ||||||
|  | 
 | ||||||
|  | 		padding: 0; | ||||||
|  | 		border-radius: 0.25em; | ||||||
|  | 		border: solid var(--background-darkest) thin; | ||||||
|  | 		background: var(--background-lighter); | ||||||
|  | 
 | ||||||
|  | 		> svg | ||||||
|  | 		{ | ||||||
|  | 			margin: auto 0.3em; | ||||||
|  | 			color: var(--neutral); | ||||||
|  | 		;} | ||||||
|  | 
 | ||||||
|  | 		> .option | ||||||
|  | 		{ | ||||||
|  | 			flex: 1; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		> button | ||||||
|  | 		{ | ||||||
|  | 			display: flex; | ||||||
|  | 			align-items: center; | ||||||
|  | 			justify-content: center; | ||||||
|  | 
 | ||||||
|  | 			padding: 0; | ||||||
|  | 			width: 1.5em; | ||||||
|  | 			height: 1.5em; | ||||||
|  | 
 | ||||||
|  | 			> svg { margin: auto; } | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								src/styles/components/select/_suggestions.less
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/styles/components/select/_suggestions.less
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | .floating.suggestions | ||||||
|  | { | ||||||
|  | 	padding: 0.5em 0; | ||||||
|  | 
 | ||||||
|  | 	.suggestion | ||||||
|  | 	{ | ||||||
|  | 		transition: background 0.2s ease; | ||||||
|  | 
 | ||||||
|  | 		position: relative; | ||||||
|  | 
 | ||||||
|  | 		display: block; | ||||||
|  | 		padding: 0.5em 1em; | ||||||
|  | 
 | ||||||
|  | 		background: transparent; | ||||||
|  | 		color: var(--foreground); | ||||||
|  | 
 | ||||||
|  | 		cursor: pointer; | ||||||
|  | 
 | ||||||
|  | 		&.preselected | ||||||
|  | 		{ | ||||||
|  | 			background: var(--background); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		&:hover | ||||||
|  | 		{ | ||||||
|  | 			background: var(--background-darker); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		> .selected | ||||||
|  | 		{ | ||||||
|  | 			position: absolute; | ||||||
|  | 			top: 0; | ||||||
|  | 			right: 0.5em; | ||||||
|  | 			bottom: 0; | ||||||
|  | 
 | ||||||
|  | 			display: flex; | ||||||
|  | 			align-items: center; | ||||||
|  | 			justify-content: center; | ||||||
|  | 			margin: auto; | ||||||
|  | 			width: 1.4em; | ||||||
|  | 			height: 1.4em; | ||||||
|  | 			border-radius: 1.4em; | ||||||
|  | 
 | ||||||
|  | 			color: var(--green); | ||||||
|  | 		} | ||||||
|  | 		&:not(.selected) > .selected | ||||||
|  | 		{ | ||||||
|  | 			display: none; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue