Selectors, style generalization, classes merging generalization, background colors.

This commit is contained in:
Madeorsk 2024-07-06 14:13:09 +02:00
parent 08ba629b78
commit e15861393e
Signed by: Madeorsk
SSH key fingerprint: SHA256:J9G0ofIOLKf7kyS2IfrMqtMaPdfsk1W02+oGueZzDDU
26 changed files with 717 additions and 27 deletions

View file

@ -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>

View file

@ -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>
); );

View file

@ -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>

View file

@ -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>

View file

@ -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>
} }
</> </>
); );
} })

View file

@ -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}

View file

@ -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}

View file

@ -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} />

View file

@ -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}

View file

@ -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>;
} }

View file

@ -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}

View 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>
));
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View file

@ -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, "")
: "";
}

View file

@ -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;
} }

View file

@ -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";

View file

@ -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;
}
} }

View file

@ -0,0 +1,3 @@
@import "select/_input";
@import "select/_selected";
@import "select/_suggestions";

View file

@ -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;

View file

@ -28,4 +28,10 @@ label
color: var(--red); color: var(--red);
} }
} }
span.description
{
color: var(--foreground-lightest);
font-size: 0.9em;
}
} }

View file

@ -7,7 +7,6 @@ label.password
> input > input
{ {
padding-right: 2.5em; padding-right: 2.5em;
padding-right: 2.5em;
} }
a.button a.button

View 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; }
}
}
}
}

View 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; }
}
}
}

View 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;
}
}
}