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…
Reference in a new issue