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 {DatepickerInput} from "../src/Components/Forms/DatepickerInput";
|
||||
import {TimepickerInput} from "../src/Components/Forms/TimepickerInput";
|
||||
import {Select} from "../src/Components/Select/Select";
|
||||
|
||||
export function DemoApp()
|
||||
{
|
||||
const [datetime, setDatetime] = useState(null);
|
||||
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
return (
|
||||
<main className={"app"}>
|
||||
<h1>KernelUI</h1>
|
||||
|
@ -22,11 +25,9 @@ export function DemoApp()
|
|||
<h2>TODO</h2>
|
||||
|
||||
<ul>
|
||||
<li>Popover / Tooltips</li>
|
||||
<li>Datepicker / Timepicker</li>
|
||||
<li>Selects</li>
|
||||
<li>Loaders</li>
|
||||
<li>Menu</li>
|
||||
<li>Dropdown menus</li>
|
||||
<li>Main menu</li>
|
||||
<li>Tabs / Apps selectors</li>
|
||||
<li>App steps</li>
|
||||
<li>Pagination</li>
|
||||
|
@ -90,6 +91,17 @@ export function DemoApp()
|
|||
<Checkbox>Checkbox demo</Checkbox>
|
||||
<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>
|
||||
|
||||
<h2>HTML</h2>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React, {PropsWithChildren} from "react";
|
||||
import {classes} from "../Utils";
|
||||
|
||||
export function Card({children, className, ...props}: PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>): React.ReactElement
|
||||
{
|
||||
return (
|
||||
<div className={`card${className ? ` ${className}` : ""}`} {...props}>
|
||||
<div className={classes("card", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {useMemo} from "react";
|
||||
import {classes} from "../../Utils";
|
||||
|
||||
/**
|
||||
* Calendar component.
|
||||
|
@ -75,7 +76,7 @@ export function Calendar({date, onDateSelected, locale, className, ...tableProps
|
|||
}, [date, onDateSelected]);
|
||||
|
||||
return (
|
||||
<table className={`calendar${className ? ` ${className}` : ""}`} {...tableProps}>
|
||||
<table className={classes("calendar", className)} {...tableProps}>
|
||||
<thead>
|
||||
{currentMonthHeader}
|
||||
</thead>
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, {useCallback, useMemo} from "react";
|
|||
import {CaretLeft, CaretRight} from "@phosphor-icons/react";
|
||||
import {Tooltip} from "../Floating/Tooltip";
|
||||
import {Calendar} from "./Calendar";
|
||||
import {classes} from "../../Utils";
|
||||
|
||||
/**
|
||||
* Datepicker component.
|
||||
|
@ -30,7 +31,7 @@ export function Datepicker({date, onDateSelected, locale, className, ...divProps
|
|||
}, [date]);
|
||||
|
||||
return (
|
||||
<div className={`datepicker${className ? ` ${className}` : ""}`} {...divProps}>
|
||||
<div className={classes("datepicker", className)} {...divProps}>
|
||||
<div className={"year-month"}>
|
||||
{(new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"})).format(date)}
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "@floating-ui/react";
|
||||
import {UseFloatingOptions} from "@floating-ui/react/dist/floating-ui.react";
|
||||
import {mergeRefs} from "react-merge-refs";
|
||||
import {classes} from "../../Utils";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* 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}: {
|
||||
children: (React.ReactElement & React.ClassAttributes<HTMLElement>)|Managed<(React.ReactElement & React.ClassAttributes<HTMLElement>)>;
|
||||
export type FloatChild = (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>;
|
||||
className?: string;
|
||||
mode?: FloatingMode;
|
||||
role?: FloatRole;
|
||||
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.
|
||||
if (!mode) mode = "always";
|
||||
|
||||
|
@ -101,13 +112,13 @@ export function Float({children, content, className, mode, role, floatingOptions
|
|||
Object.assign(
|
||||
{
|
||||
// Pass references.
|
||||
ref: mergeRefs([refs.setReference, child?.ref]),
|
||||
ref: mergeRefs([ref, refs.setReference, child?.ref]),
|
||||
},
|
||||
// Get interaction properties.
|
||||
getReferenceProps(),
|
||||
),
|
||||
);
|
||||
}, [children, show, hide, refs.setReference, getReferenceProps]);
|
||||
}, [children, show, hide, ref, refs.setReference, getReferenceProps]);
|
||||
|
||||
// Update floating content.
|
||||
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.
|
||||
isMounted &&
|
||||
<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className={"floating"}>
|
||||
<Card style={transitionStyles} className={`floating${className ? ` ${className}` : ""}`}>
|
||||
<Card style={transitionStyles} className={classes("floating", className)}>
|
||||
{floatingContent}
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import React from "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
|
||||
{
|
||||
return (
|
||||
<label className={`box${className ? ` ${className}` : ""}`}>
|
||||
<label className={classes("box", className)}>
|
||||
<input type={"checkbox"} {...inputProps} />
|
||||
<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a>
|
||||
{children}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 {Datepicker} from "../Dates/Datepicker";
|
||||
|
||||
|
@ -101,7 +101,7 @@ export function DatepickerInput(
|
|||
}, [dateText, onChange]);
|
||||
|
||||
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.
|
||||
onFocus={useCallback(() => inputRef.current?.focus(), [inputRef])}>
|
||||
{children}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import React, {useState} from "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
|
||||
{
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<label className={`password${className ? ` ${className}` : ""}`}>
|
||||
<label className={classes("password", className)}>
|
||||
{children}
|
||||
<div>
|
||||
<input type={showPassword ? "text" : "password"} {...props} />
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import React from "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
|
||||
{
|
||||
return (
|
||||
<label className={`box${className ? ` ${className}` : ""}`}>
|
||||
<label className={classes("box", className)}>
|
||||
<input type={"radio"} {...inputProps} />
|
||||
<a className={"button"} tabIndex={-1}><Check weight={"bold"} /></a>
|
||||
{children}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import {classes} from "../../Utils";
|
||||
|
||||
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 {formatTime, Modify} from "../../Utils";
|
||||
import {classes, formatTime, Modify} from "../../Utils";
|
||||
|
||||
export function TimepickerInput(
|
||||
{
|
||||
|
@ -73,7 +73,7 @@ export function TimepickerInput(
|
|||
}, [timeText, timeValue, onChange]);
|
||||
|
||||
return (
|
||||
<label className={`timepicker${className ? ` ${className}` : ""}`}>
|
||||
<label className={classes("timepicker", className)}>
|
||||
{children}
|
||||
|
||||
<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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: #1DA90F; --green: @green;
|
||||
@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: #D01212; --red: @red;
|
||||
@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: #0D6DEE; --blue: @blue;
|
||||
@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: #D06112; --orange: @orange;
|
||||
@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: #CE3EBF; --pink: @pink;
|
||||
@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: #7D2AFF; --purple: @purple;
|
||||
@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: #EACD0D; --yellow: @yellow;
|
||||
@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: #4B190C; --brown: @brown;
|
||||
@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: @blue; --primary: @primary;
|
||||
@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: @orange; --secondary: @secondary;
|
||||
@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/_card";
|
||||
@import "components/_dates";
|
||||
@import "components/_floating";
|
||||
@import "components/_form";
|
||||
@import "components/_headings";
|
||||
@import "components/_link";
|
||||
@import "components/_list";
|
||||
@import "components/_floating";
|
||||
@import "components/_select";
|
||||
@import "components/_steps";
|
||||
@import "components/_table";
|
||||
|
|
|
@ -71,6 +71,22 @@ a.button, button, input[type="submit"], input[type="reset"]
|
|||
{
|
||||
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
|
||||
|
@ -86,9 +102,25 @@ a.button, button, input[type="submit"], input[type="reset"]
|
|||
{
|
||||
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);
|
||||
background: var(--red);
|
||||
|
@ -101,6 +133,22 @@ a.button, button, input[type="submit"], input[type="reset"]
|
|||
{
|
||||
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
|
||||
|
@ -110,4 +158,8 @@ a.button, button, input[type="submit"], input[type="reset"]
|
|||
margin-right: 0.2em;
|
||||
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;
|
||||
display: block;
|
||||
|
||||
width: 15em;
|
||||
padding: 0.5em;
|
||||
border-radius: 0.25em;
|
||||
box-sizing: border-box;
|
||||
|
|
|
@ -28,4 +28,10 @@ label
|
|||
color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
span.description
|
||||
{
|
||||
color: var(--foreground-lightest);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ label.password
|
|||
> input
|
||||
{
|
||||
padding-right: 2.5em;
|
||||
padding-right: 2.5em;
|
||||
}
|
||||
|
||||
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…
Reference in a new issue