Improve floating elements, add form inputs for date and time with a full datepicker.

+ Add datepicker form input.
+ Add datepicker component.
+ Add calendar component.
+ Add timepicker form input.
* Change Popover to Float, with a better library and general behavior, and some fixes.
* Floating elements visual improvements.
This commit is contained in:
Madeorsk 2024-06-15 22:53:32 +02:00
parent d2d4c9cab4
commit 0faaa6b10c
Signed by: Madeorsk
SSH key fingerprint: SHA256:J9G0ofIOLKf7kyS2IfrMqtMaPdfsk1W02+oGueZzDDU
25 changed files with 822 additions and 183 deletions

View file

@ -1,4 +1,4 @@
import React from "react";
import React, {useState} from "react";
import "../index";
import {Checkbox} from "../src/Components/Forms/Checkbox";
import { Radio } from "../src/Components/Forms/Radio";
@ -6,11 +6,15 @@ import {FloppyDisk, TrashSimple, XCircle} from "@phosphor-icons/react";
import {Card} from "../src/Components/Card";
import {PasswordInput} from "../src/Components/Forms/PasswordInput";
import {RequiredField} from "../src/Components/Forms/RequiredField";
import {Popover} from "../src/Components/Popovers/Popover";
import {Tooltip} from "../src/Components/Popovers/Tooltip";
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";
export function DemoApp()
{
const [datetime, setDatetime] = useState(null);
return (
<main className={"app"}>
<h1>KernelUI</h1>
@ -73,6 +77,16 @@ export function DemoApp()
Test password
</PasswordInput>
<DatepickerInput value={datetime} onChange={setDatetime}>
Date test
</DatepickerInput>
<TimepickerInput value={datetime} onChange={setDatetime}>
Time test
</TimepickerInput>
<p>Currently selected datetime: <strong>{datetime ? datetime.toISOString() : "none"}</strong></p>
<Checkbox>Checkbox demo</Checkbox>
<Radio name={"radio-test"}>Radio box test</Radio>
<Radio name={"radio-test"}>Radio box test</Radio>
@ -183,33 +197,33 @@ export function DemoApp()
<h2>Popovers</h2>
<Popover mode={"hover"} content={"Do you see me?"}>
<Float mode={"hover"} content={"Do you see me?"}>
<button type={"button"}>Hover me!</button>
</Popover>
</Float>
<Popover mode={"focus"} content={<>I am <strong>focused</strong></>}>
<Float mode={"focus"} content={<>I am <strong>focused</strong></>}>
<button>Focus me!</button>
</Popover>
</Float>
<Popover mode={"click"} content={(
<Float mode={"click"} content={(
<div>
You can add complex (clickable) content in me.
<button type={"button"}>OK</button>
</div>
)}>
<button>Click me!</button>
</Popover>
</Float>
<Popover content={"I am always shown."} popperOptions={{ placement: "top" }}>
<Float mode={"always"} content={"I am always shown."} floatingOptions={{ placement: "top" }}>
<button>Why always me?</button>
</Popover>
</Float>
<div>
<Popover mode={"managed"} content={(show, hide) => (<button onClick={hide}>I can hide the popover!</button>)}>
<Float mode={"managed"} content={(show, hide) => (<button onClick={hide}>I can hide the popover!</button>)}>
{(show, hide) => (
<button type={"button"} onClick={show}>Customized behavior</button>
)}
</Popover>
</Float>
</div>
<h2>Tooltips</h2>

View file

@ -11,13 +11,13 @@
"types": "lib/index.d.ts",
"main": "lib/index.js",
"dependencies": {
"@floating-ui/react": "^0.26.17",
"@fontsource-variable/jetbrains-mono": "^5.0.21",
"@fontsource-variable/manrope": "^5.0.20",
"@fontsource-variable/source-serif-4": "^5.0.19",
"@phosphor-icons/react": "^2.1.5",
"@popperjs/core": "^2.11.8",
"react": "^18.3.1",
"react-popper": "^2.3.0"
"react-merge-refs": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.3",

View file

@ -0,0 +1,107 @@
import React, {useMemo} from "react";
/**
* Calendar component.
*/
export function Calendar({date, onDateSelected, locale, className, ...tableProps}: {
date: Date;
onDateSelected: (date: Date) => void;
locale?: string;
} & React.TableHTMLAttributes<HTMLTableElement>): React.ReactElement
{
locale = useMemo(() => (locale ?? "fr"), [locale]);
const currentMonthHeader = useMemo(() => (
<tr>
{ // For each day of the week, showing its name.
[1, 2, 3, 4, 5, 6, 7].map(day => {
// Getting a date with the right day of the week.
const dayOfWeek = new Date(1970, 0, 4 + day);
return (
<th key={day}>
{(new Intl.DateTimeFormat(locale, {weekday: "short"})).format(dayOfWeek)}
</th>
);
})
}
</tr>
), [date]);
const currentMonthTable = useMemo(() => {
// Initialize weeks.
const weeksRows = [];
let currentWeek = [];
// Get start date of the calendar.
let currentDate = new Date(date);
currentDate.setDate(1); // First day of the month.
currentDate.setDate(currentDate.getDate() - (currentDate.getDay() - 1 + 7) % 7); // Searching the start of the first week of the current month.
// Get last day of the calendar.
const lastDate = new Date(date);
lastDate.setMonth(lastDate.getMonth() + 1, 0); // Get the last day of the month.
lastDate.setDate(lastDate.getDate() + (7 - lastDate.getDay())); // Searching the end of the last week of the current month.
while (currentDate.getTime() <= lastDate.getTime() || weeksRows.length < 6)
{ // While the current date is before or is the last day of the current view,
// adding the current day to the current week.
currentWeek.push(
<Day key={`${currentDate.getFullYear()}-${currentDate.getMonth()}-${currentDate.getDate()}`}
date={new Date(currentDate)}
faded={date.getMonth() != currentDate.getMonth()}
selected={date.getTime() == currentDate.getTime()}
onClick={onDateSelected} />
);
// We're on sunday, adding the current week and creating a new one.
if (currentDate.getDay() == 0)
{ // The current week is ended, adding it and creating a new one.
weeksRows.push(
<tr key={`${currentDate.getFullYear()}-${currentDate.getMonth()}-${currentDate.getDate()}`}>
{currentWeek}
</tr>
);
currentWeek = []; // Reset the current week to a new one.
}
// Get the next day.
currentDate.setDate(currentDate.getDate() + 1);
}
// Return generated weeks rows.
return weeksRows;
}, [date, onDateSelected]);
return (
<table className={`calendar${className ? ` ${className}` : ""}`} {...tableProps}>
<thead>
{currentMonthHeader}
</thead>
<tbody>
{currentMonthTable}
</tbody>
</table>
);
}
/**
* Calendar day component.
*/
function Day({date, onClick, faded, selected}: {
date: Date;
onClick?: (date: Date) => void;
faded: boolean;
selected: boolean;
}): React.ReactElement
{
return (
<td key={`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`}>
<a className={`day${faded ? " faded" : ""}${selected ? " selected" : ""}`}
onClick={() => onClick?.(date)}>
{date.getDate()}
</a>
</td>
);
}

View file

@ -0,0 +1,64 @@
import React, {useCallback, useMemo} from "react";
import {CaretLeft, CaretRight} from "@phosphor-icons/react";
import {Tooltip} from "../Floating/Tooltip";
import {Calendar} from "./Calendar";
/**
* Datepicker component.
*/
export function Datepicker({date, onDateSelected, locale, className, ...divProps}: {
date: Date;
onDateSelected: (date: Date) => void;
locale?: string;
} & React.HTMLAttributes<HTMLDivElement>): React.ReactElement
{
locale = useMemo(() => (locale ?? "fr"), [locale]);
// Get previous month name.
const previousMonthName = useMemo(() => {
// Copy the current date and get back one month earlier.
const previousMonthDate = new Date(date);
previousMonthDate.setMonth(previousMonthDate.getMonth() - 1);
return (new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" })).format(previousMonthDate);
}, [date]);
// Get next month name.
const nextMonthName = useMemo(() => {
// Copy the current date and go to one month later.
const nextMonthDate = new Date(date);
nextMonthDate.setMonth(nextMonthDate.getMonth() + 1);
return (new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" })).format(nextMonthDate);
}, [date]);
return (
<div className={`datepicker${className ? ` ${className}` : ""}`} {...divProps}>
<div className={"year-month"}>
{(new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"})).format(date)}
</div>
<Calendar date={date} onDateSelected={onDateSelected} />
<Tooltip content={previousMonthName}>
<button type={"button"} className={"previous-month"} onClick={
useCallback((event) => {
const newDate = new Date(date);
newDate.setMonth(newDate.getMonth() - 1);
onDateSelected(newDate);
}, [date, onDateSelected])
}>
<CaretLeft weight={"bold"}/>
</button>
</Tooltip>
<Tooltip content={nextMonthName}>
<button type={"button"} className={"next-month"} onClick={
useCallback((event) => {
const newDate = new Date(date);
newDate.setMonth(newDate.getMonth() + 1);
onDateSelected(newDate);
}, [date, onDateSelected])
}>
<CaretRight weight={"bold"}/>
</button>
</Tooltip>
</div>
);
}

View file

@ -0,0 +1,122 @@
import React, {useCallback, useMemo, useState} from "react";
import {Card} from "../Card";
import {
shift,
useClick,
useFloating,
useFocus,
useHover,
useInteractions, useTransitionStyles
} from "@floating-ui/react";
import {UseFloatingOptions} from "@floating-ui/react/dist/floating-ui.react";
import {mergeRefs} from "react-merge-refs";
/**
* Fully managed floating content function.
*/
export type Managed<T = React.ReactElement|React.ReactNode> = (show: () => void, hide: () => void) => T;
/**
* Allowed floating modes.
*/
export type FloatingMode = "always"|"click"|"hover"|"focus"|"managed";
/**
* A component to show something floating next to an element.
*/
export function Float({children, content, className, mode, floatingOptions}: {
children: (React.ReactElement & React.ClassAttributes<HTMLElement>)|Managed<(React.ReactElement & React.ClassAttributes<HTMLElement>)>;
content?: React.ReactNode|Managed<React.ReactNode>;
className?: string;
mode?: FloatingMode;
floatingOptions?: UseFloatingOptions;
}): React.ReactElement
{
// By default, use "always" mode.
if (!mode) mode = "always";
// Followed show status.
const [shown, setShown] = useState(false);
// Create show / hide functions.
const show = useCallback(() => setShown(true), [setShown]);
const hide = useCallback(() => setShown(false), [setShown]);
// If show mode is "always", always show the floating part after render.
if (mode == "always")
{
setTimeout(() => {
setShown(true);
}, 0);
}
// Floating initialization.
const { refs, floatingStyles, context } = useFloating(
useMemo(() => (Object.assign({
open: shown,
onOpenChange: setShown,
middleware: [shift()],
} as UseFloatingOptions, floatingOptions)), [floatingOptions, shown, setShown])
);
// Interactions initialization.
const hover = useHover(context, useMemo(() => ({ enabled: mode == "hover" }), [mode]));
const focus = useFocus(context, useMemo(() => ({ enabled: mode == "focus", visibleOnly: false }), [mode]));
const click = useClick(context, useMemo(() => ({ enabled: mode == "click" }), [mode]));
const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, click]);
// Transition configuration.
const {isMounted, styles: transitionStyles} = useTransitionStyles(context, {
duration: 200,
common: ({side}) => ({
transformOrigin: {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
}[side],
}),
initial: {
transform: "scale(0.66)",
opacity: "0",
},
});
// Change the child element to use the reference and the interactions properties.
const referencedChild = useMemo(() => {
// Render the children if a managed floating function is passed.
const child = typeof children == "function" ? children(show, hide) : children;
return React.cloneElement(child,
Object.assign(
{
// Pass references.
ref: mergeRefs([refs.setReference, child?.ref]),
},
// Get interaction properties.
getReferenceProps(),
),
);
}, [children, show, hide, refs.setReference, getReferenceProps]);
// Update floating content.
const floatingContent = useMemo(() => (
// Render the children if a managed floating function is passed.
typeof content == "function" ? content(show, hide) : content
), [shown, show, hide, content]);
return (
<>
{referencedChild}
{ // 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}` : ""}`}>
{floatingContent}
</Card>
</div>
}
</>
);
}

View file

@ -1,5 +1,5 @@
import React from "react";
import {Popover} from "./Popover";
import {Float} from "./Float";
export function Tooltip({children, content}: {
children: React.ReactElement;
@ -7,8 +7,8 @@ export function Tooltip({children, content}: {
}): React.ReactElement
{
return (
<Popover mode={"hover"} content={content} className={"tooltip"} popperOptions={{ placement: "top" }}>
<Float mode={"hover"} content={content} className={"tooltip"} floatingOptions={{ placement: "top" }}>
{children}
</Popover>
</Float>
);
}

View file

@ -0,0 +1,155 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {formatDate, Modify} from "../../Utils";
import {Float} from "../Floating/Float";
import {Datepicker} from "../Dates/Datepicker";
/**
* A form input for a date with a datepicker.
*/
export function DatepickerInput(
{
children, className,
value, onChange,
// Properties to pass down.
onKeyUp, onBlur,
// Already set properties.
type, placeholder,
...props}: React.PropsWithChildren<Modify<React.InputHTMLAttributes<HTMLInputElement>, {
/**
* The current date value.
*/
value?: Date|null;
/**
* Called when picked date is changed.
* @param newDate The new date.
*/
onChange: (newDate: Date) => void;
// Already set properties.
type?: never;
placeholder?: never;
}>>): React.ReactElement
{
// Date text state.
const [dateText, setDateText] = useState("");
// Update date text when date value has changed.
useEffect(() => {
if (value && value instanceof Date && !isNaN(value.getTime()))
setDateText(formatDate(value));
}, [value]);
// Check if date is valid.
const invalidDate = useMemo(() => !(value && value instanceof Date && !isNaN(value.getTime())) && dateText.length > 0, [value, dateText]);
const dateValue = useMemo(() => invalidDate ? new Date() : (value ?? new Date()), [invalidDate, value]);
const inputRef = useRef<HTMLInputElement>();
/**
* Submit a new date from its raw text.
*/
const submitDate = useCallback((customDateText?: string) => {
// Get the current date text status.
const dateTextMatch = (customDateText ?? dateText).match(/^([0-9]?[0-9])\/([0-9]?[0-9])\/([0-9]?[0-9]?[0-9]?[0-9])$/);
if (dateTextMatch)
{
// Parse day.
let day = dateTextMatch[1];
if (day.length < 2) day = "0" + day;
if (parseInt(day) <= 0) day = "01";
// Parse month.
let month = dateTextMatch[2];
if (month.length < 2) month = "0" + month;
if (parseInt(month) <= 0) month = "01";
// Parse year.
let year = parseInt(dateTextMatch[3]);
if (year < 100)
{
year += 1900;
if ((new Date()).getFullYear() - year > 96) year += 100;
}
if (year < 1000) year += 1000;
// Try to build the structurally valid date.
const date = new Date(year + "-" + month + "-" + day);
if (!isNaN(date.getTime()))
{ // Date is valid, checking that it uses the right month (to fix the behavior when we go change 31/03 to 31/02, we want 28/02 or 29/02 and not 01/03).
if ((date.getMonth() + 1) > parseInt(month))
{ // Current date month is not valid, we're getting back to it.
date.setDate(date.getDate() - 1);
}
if ((date.getMonth() + 1) < parseInt(month))
{ // Current date month is not valid, we're getting back to it.
date.setDate(date.getDate() + 1);
}
}
// Try to keep original hours.
date.setHours(value?.getHours() ?? 0, value?.getMinutes() ?? 0, value?.getSeconds() ?? 0, value?.getMilliseconds() ?? 0);
// Set the structurally valid date.
onChange?.(date);
}
else
// No structurally valid date, removing it.
onChange?.(null);
}, [dateText, onChange]);
return (
<label className={`datepicker-input${invalidDate ? " error" : ""}${className ? ` ${className}` : ""}`}
// Keeping focus on the input when something else in the label takes it.
onFocus={useCallback(() => inputRef.current?.focus(), [inputRef])}>
{children}
<Float mode={"focus"} className={"datepicker"} content={
<Datepicker date={dateValue} onDateSelected={onChange} />
}>
<input type={"text"} ref={inputRef} placeholder={"DD/MM/AAAA"} value={dateText}
onChange={useCallback((event) => {
let dateText = event.currentTarget.value;
// Add first '/' if missing.
if (dateText.match(/^[0-9]{2}[^\/]*$/))
dateText = dateText.substring(0, 2) + "/" + dateText.substring(2);
// Add second '/' if missing.
if (dateText.match(/^[0-9]{2}\/[0-9]{2}[^\/]*$/))
dateText = dateText.substring(0, 5) + "/" + dateText.substring(5);
// Set the new date text.
setDateText(dateText);
// If a full date has been entered, reading it.
const fullDateMatch = dateText.match(/^([0-9]{2})\/([0-9]{2})\/([0-9]{4})$/);
if (fullDateMatch)
{ // We have a structurally valid date, submitting it.
submitDate(dateText);
}
}, [submitDate])}
onKeyUp={useCallback((event) => {
if (event.key == "Enter")
// Submit date when enter is pressed.
submitDate();
return onKeyUp?.(event);
}, [submitDate, onKeyUp])}
onBlur={useCallback((event) => {
// Submit date when leaving form input.
submitDate();
return onBlur?.(event);
}, [submitDate, onBlur])}
{...props} />
</Float>
{ // The date is invalid, showing a subtext to say so.
invalidDate && (
<span className={"error subtext"}>Invalid date.</span>
)
}
</label>
);
}

View file

@ -0,0 +1,117 @@
import React, {useCallback, useEffect, useMemo, useState} from "react";
import {formatTime, Modify} from "../../Utils";
export function TimepickerInput(
{
children, className,
value, onChange,
// Properties to pass down.
onKeyUp, onBlur,
// Already set properties.
type, placeholder,
...props
}: React.PropsWithChildren<Modify<React.InputHTMLAttributes<HTMLInputElement>, {
value?: Date|null;
onChange: (newDateTime: Date) => void;
// Already set properties.
type?: never;
placeholder?: never;
}>>): React.ReactElement
{
// Time text state.
const [timeText, setTimeText] = useState("");
// Update time text when datetime value has changed.
useEffect(() => {
if (value && value instanceof Date && !isNaN(value.getTime()))
setTimeText(formatTime(value));
}, [value]);
// Check if time is valid.
const invalidTime = useMemo(() => !(value && value instanceof Date && !isNaN(value.getTime())) && timeText.length > 0, [value, timeText]);
const timeValue = useMemo(() => invalidTime ? new Date() : (value ?? new Date()), [invalidTime, value]);
/**
* Submit a new time from its raw text.
*/
const submitTime = useCallback((customTimeText?: string) => {
// Get the current date text status.
const timeTextMatch = (customTimeText ?? timeText).match(/^([0-2]?[0-9]):([0-5]?[0-9])$/);
if (timeTextMatch)
{
// Parse hours.
let rawHours = timeTextMatch[1];
if (rawHours.length < 2) rawHours = "0" + rawHours;
let hours = parseInt(rawHours);
if (isNaN(hours)) hours = 0;
// Parse minutes.
let rawMinutes = timeTextMatch[2];
if (rawMinutes.length < 2) rawMinutes = "0" + rawMinutes;
let minutes = parseInt(rawMinutes);
if (isNaN(minutes)) minutes = 0;
//TODO
// Parse seconds?
// Try to build the structurally valid date with this time.
//TODO
const datetime = new Date(timeValue);
datetime.setHours(hours, minutes);
// Put the date back, if it changed to another day.
datetime.setFullYear(timeValue.getFullYear(), timeValue.getMonth(), timeValue.getDate());
// Set the structurally valid date.
onChange?.(datetime);
}
else
// No structurally valid date, removing it.
onChange?.(null);
}, [timeText, timeValue, onChange]);
return (
<label className={`timepicker${className ? ` ${className}` : ""}`}>
{children}
<input type={"text"} placeholder={"HH:MM"} value={timeText}
onChange={useCallback((event) => {
let timeText = event.currentTarget.value;
// Add first ':' if missing.
if (timeText.match(/^[0-9]{2}[^:]*$/))
timeText = timeText.substring(0, 2) + ":" + timeText.substring(2);
// Set the new time text.
setTimeText(timeText);
// If a full time has been entered, reading it.
const fullTimeMatch = timeText.match(/^([0-2][0-9]):([0-5][0-9])$/);
if (fullTimeMatch)
{ // We have a structurally valid time, submitting it.
submitTime(timeText);
}
}, [submitTime])}
onKeyUp={useCallback((event) => {
if (event.key == "Enter")
// Submit time when enter is pressed.
submitTime();
return onKeyUp?.(event);
}, [submitTime, onKeyUp])}
onBlur={useCallback((event) => {
// Submit time when leaving form input.
submitTime();
return onBlur?.(event);
}, [submitTime, onBlur])}
{...props} />
{ // The date is invalid, showing a subtext to say so.
invalidTime && (
<span className={"error subtext"}>Invalid time.</span>
)
}
</label>
);
}

View file

@ -1,96 +0,0 @@
import React, {useMemo, useRef, useState} from "react";
import {usePopper} from "react-popper";
import * as PopperJS from "@popperjs/core";
import {Card} from "../Card";
/**
* Fully managed popover content function.
*/
export type Managed<T = React.ReactElement|React.ReactNode> = (show: () => void, hide: () => void) => T;
/**
* Allowed popover modes.
*/
export type PopoverMode = "always"|"click"|"hover"|"focus"|"managed";
/**
* A component to show something next to an element.
*/
export function Popover({children, content, className, mode, popperOptions}: {
children: React.ReactElement|Managed<React.ReactElement>;
content?: React.ReactNode|Managed<React.ReactNode>;
className?: string;
mode?: PopoverMode;
popperOptions?: Partial<PopperJS.Options>;
}): React.ReactElement
{
// By default, use "always" mode.
if (!mode) mode = "always";
// Followed show status.
const [shown, setShown] = useState(false);
// Create show / hide functions.
const show = useMemo(() => (!shown ? () => setShown(true) : () => {}), [shown]);
const hide = useMemo(() => (shown ? () => setShown(false) : () => {}), [shown]);
// If show mode is "always", always show the popover after render.
setTimeout(() => {
if (mode == "always") setShown(true);
}, 0);
// HTML elements references.
const referenceElement = useRef();
const popperElement = useRef();
// Change the child element to use the reference.
const referencedChild = useMemo(() => (
React.cloneElement(
// Render the children if a managed popover function is passed.
typeof children == "function" ? children(show, hide) : children,
Object.assign(
{
ref: referenceElement,
},
// Pass click event if click mode is enabled.
mode == "click" ? {
onClick: (event: React.MouseEvent): void => {
setShown(!shown);
},
} : {},
// Pass focus events if focus mode is enabled.
mode == "focus" ? {
onFocus: show,
onBlur: hide,
} : {},
// Pass the hover event if hover mode is enabled.
mode == "hover" ? {
onMouseEnter: show,
onMouseOut: hide,
} : {},
))
), [mode, shown, show, hide, children]);
// Update popover content.
const popoverContent = useMemo(() => (
// Render the children if a managed popover function is passed.
typeof content == "function" ? content(show, hide) : content
), [shown, show, hide, content]);
// Initialize popper.
const { styles, attributes } = usePopper(
referenceElement?.current, popperElement?.current, popperOptions
);
return (
<>
{referencedChild}
<div ref={popperElement} style={styles.popper} {...attributes.popper} className={"popover"} hidden={!shown}>
<Card className={`popover${className ? ` ${className}` : ""}`}>
{popoverContent}
</Card>
</div>
</>
);
}

12
src/Utils.tsx Normal file
View file

@ -0,0 +1,12 @@
export type Modify<T, R> = Omit<T, keyof R> & R;
export function formatDate(date: Date): string
{
return ((date.getDate() < 10 ? "0" : "") + date.getDate()) + "/" + (((date.getMonth() + 1) < 10 ? "0" : "") + (date.getMonth() + 1)) + "/" + date.getFullYear();
}
export function formatTime(date: Date): string
{
return (date.getHours() < 10 ? "0" : "") + date.getHours() + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes();
}

View file

@ -1,10 +1,11 @@
@import "components/_button";
@import "components/_card";
@import "components/_dates";
@import "components/_form";
@import "components/_headings";
@import "components/_link";
@import "components/_list";
@import "components/_popover";
@import "components/_floating";
@import "components/_steps";
@import "components/_table";

View file

@ -30,7 +30,7 @@
margin-bottom: 0;
}
&.popover
&.floating
{
width: 20em;
}

View file

@ -0,0 +1,3 @@
@import "dates/_calendar";
@import "dates/_datepicker";

View file

@ -0,0 +1,2 @@
@import "floating/_floating";
@import "floating/_tooltip";

View file

@ -1,5 +1,6 @@
@import "forms/_box";
@import "forms/_datepicker-input";
@import "forms/_input";
@import "forms/_label";
@import "forms/_password-input";

View file

@ -1,2 +0,0 @@
@import "popovers/_popover";
@import "popovers/_tooltip";

View file

@ -0,0 +1,57 @@
table.calendar
{
thead th
{
text-align: center;
}
tbody > tr
{
border-bottom: none;
&:nth-child(even) { background: transparent; }
&:hover { background: transparent; }
> td
{
padding: 0;
text-align: center;
}
}
a.day
{
transition: background 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
border-radius: 50%;
background: transparent;
color: var(--foreground);
font-weight: 400;
text-align: center;
cursor: pointer;
&:hover
{
background: var(--background-darker);
}
&.selected
{
background: var(--primary);
color: var(--background);
}
&.faded
{
opacity: 0.4;
}
}
}

View file

@ -0,0 +1,44 @@
.datepicker
{
position: relative;
padding: 0 1em;
background: var(--background-lighter);
font-size: 0.9em;
.year-month
{
font-size: 1.1em;
text-align: center;
}
button
{
&.previous-month, &.next-month
{
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
margin: auto;
padding: 0;
width: 2em;
height: 2em;
svg
{ margin: 0; }
}
&.previous-month
{
left: -1.1em;
}
&.next-month
{
right: -1.1em;
}
}
}

View file

@ -0,0 +1,10 @@
:not(.card).floating
{
display: block;
z-index: 1;
> .card.floating
{
margin: 0.4em;
}
}

View file

@ -1,4 +1,4 @@
.card.popover.tooltip
.card.floating.tooltip
{
width: unset;
max-width: 20em;

View file

@ -0,0 +1,9 @@
label.datepicker-input
{
.card.datepicker
{
padding: 0.2em;
width: fit-content;
max-width: unset;
}
}

View file

@ -11,4 +11,21 @@ label
{
&::after { content: "*"; color: var(--red); font-weight: 600; }
}
&.error
{
input, textarea, select { border-color: var(--red); }
}
span.subtext
{
display: block;
font-size: 0.9em;
text-align: center;
&.error
{
color: var(--red);
}
}
}

View file

@ -1,27 +0,0 @@
:not(.card).popover
{
transition: opacity 0.2s ease;
display: flex;
z-index: 1;
opacity: 1;
&[data-popper-placement="top"], &[data-popper-placement="bottom"]
{ justify-content: center; }
&[data-popper-placement="right"]
{ justify-content: left; }
&[data-popper-placement="left"]
{ justify-content: right; }
> .card.popover
{
margin: 0.4em;
}
&[hidden]
{
opacity: 0;
pointer-events: none;
}
}

103
yarn.lock
View file

@ -430,6 +430,58 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/core@npm:^1.0.0":
version: 1.6.2
resolution: "@floating-ui/core@npm:1.6.2"
dependencies:
"@floating-ui/utils": "npm:^0.2.0"
checksum: 10c0/db2621dc682e7f043d6f118d087ae6a6bfdacf40b26ede561760dd53548c16e2e7c59031e013e37283801fa307b55e6de65bf3b316b96a054e4a6a7cb937c59e
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.0.0":
version: 1.6.5
resolution: "@floating-ui/dom@npm:1.6.5"
dependencies:
"@floating-ui/core": "npm:^1.0.0"
"@floating-ui/utils": "npm:^0.2.0"
checksum: 10c0/ebdc14806f786e60df8e7cc2c30bf9cd4d75fe734f06d755588bbdef2f60d0a0f21dffb14abdc58dea96e5577e2e366feca6d66ba962018efd1bc91a3ece4526
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.1.0":
version: 2.1.0
resolution: "@floating-ui/react-dom@npm:2.1.0"
dependencies:
"@floating-ui/dom": "npm:^1.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/9ee44dfeb27f585fb1e0114cbe37c72ff5d34149900f4f3013f6b0abf8c3365eab13286c360f97fbe0c44bb91a745e7a4c18b82d111990b45a7a7796dc55e461
languageName: node
linkType: hard
"@floating-ui/react@npm:^0.26.17":
version: 0.26.17
resolution: "@floating-ui/react@npm:0.26.17"
dependencies:
"@floating-ui/react-dom": "npm:^2.1.0"
"@floating-ui/utils": "npm:^0.2.0"
tabbable: "npm:^6.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/3d3a995aff5e905acd1fc4a3a7c92beba801b1d241f5ee1a05667c98dc1fcb9a0cc6ec82d4330fd84a0fd9b9fafa50f76dee886819659c49371733caa8d48b12
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.0":
version: 0.2.2
resolution: "@floating-ui/utils@npm:0.2.2"
checksum: 10c0/b2becdcafdf395af1641348da0031ff1eaad2bc60c22e14bd3abad4acfe2c8401e03097173d89a2f646a99b75819a78ef21ebb2572cab0042a56dd654b0065cd
languageName: node
linkType: hard
"@fontsource-variable/jetbrains-mono@npm:^5.0.21":
version: 5.0.21
resolution: "@fontsource-variable/jetbrains-mono@npm:5.0.21"
@ -599,13 +651,6 @@ __metadata:
languageName: node
linkType: hard
"@popperjs/core@npm:^2.11.8":
version: 2.11.8
resolution: "@popperjs/core@npm:2.11.8"
checksum: 10c0/4681e682abc006d25eb380d0cf3efc7557043f53b6aea7a5057d0d1e7df849a00e281cd8ea79c902a35a414d7919621fc2ba293ecec05f413598e0b23d5a1e63
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^5.1.0":
version: 5.1.0
resolution: "@rollup/pluginutils@npm:5.1.0"
@ -1769,18 +1814,18 @@ __metadata:
version: 0.0.0-use.local
resolution: "kernel-ui-core@workspace:."
dependencies:
"@floating-ui/react": "npm:^0.26.17"
"@fontsource-variable/jetbrains-mono": "npm:^5.0.21"
"@fontsource-variable/manrope": "npm:^5.0.20"
"@fontsource-variable/source-serif-4": "npm:^5.0.19"
"@phosphor-icons/react": "npm:^2.1.5"
"@popperjs/core": "npm:^2.11.8"
"@types/react": "npm:^18.3.3"
"@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.0"
less: "npm:^4.2.0"
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
react-popper: "npm:^2.3.0"
react-merge-refs: "npm:^2.1.1"
typescript: "npm:^5.4.5"
vite: "npm:^5.2.11"
vite-plugin-dts: "npm:^3.9.1"
@ -1850,7 +1895,7 @@ __metadata:
languageName: node
linkType: hard
"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0":
"loose-envify@npm:^1.1.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
@ -2254,24 +2299,10 @@ __metadata:
languageName: node
linkType: hard
"react-fast-compare@npm:^3.0.1":
version: 3.2.2
resolution: "react-fast-compare@npm:3.2.2"
checksum: 10c0/0bbd2f3eb41ab2ff7380daaa55105db698d965c396df73e6874831dbafec8c4b5b08ba36ff09df01526caa3c61595247e3269558c284e37646241cba2b90a367
languageName: node
linkType: hard
"react-popper@npm:^2.3.0":
version: 2.3.0
resolution: "react-popper@npm:2.3.0"
dependencies:
react-fast-compare: "npm:^3.0.1"
warning: "npm:^4.0.2"
peerDependencies:
"@popperjs/core": ^2.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
checksum: 10c0/23f93540537ca4c035425bb8d5e51b11131fbc921d7ac1d041d0ae557feac8c877f3a012d36b94df8787803f52ed81e6df9257ac9e58719875f7805518d6db3f
"react-merge-refs@npm:^2.1.1":
version: 2.1.1
resolution: "react-merge-refs@npm:2.1.1"
checksum: 10c0/b68cbc4ea51fd96f77247733388738630769d928f32958e63262ce43dff10c6b20f18a0483c6a640a1ffba48f32be26059d4ee9711881cacc63297b01311c8f6
languageName: node
linkType: hard
@ -2635,6 +2666,13 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.0.0":
version: 6.2.0
resolution: "tabbable@npm:6.2.0"
checksum: 10c0/ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898
languageName: node
linkType: hard
"tar@npm:^6.1.11, tar@npm:^6.1.2":
version: 6.2.1
resolution: "tar@npm:6.2.1"
@ -2844,15 +2882,6 @@ __metadata:
languageName: node
linkType: hard
"warning@npm:^4.0.2":
version: 4.0.3
resolution: "warning@npm:4.0.3"
dependencies:
loose-envify: "npm:^1.0.0"
checksum: 10c0/aebab445129f3e104c271f1637fa38e55eb25f968593e3825bd2f7a12bd58dc3738bb70dc8ec85826621d80b4acfed5a29ebc9da17397c6125864d72301b937e
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"