Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
af7f024af9
New setup for V2. 2025-09-10 23:38:56 +02:00
120 changed files with 2445 additions and 7179 deletions

4
.gitignore vendored
View file

@ -21,5 +21,7 @@ node_modules/
.pnp.* .pnp.*
# Library # Library
lib/ lib/
*storybook.log
storybook-static

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"useTabs": true
}

16
.storybook/main.ts Normal file
View file

@ -0,0 +1,16 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-docs"
],
"framework": {
"name": "@storybook/react-vite",
"options": {}
}
};
export default config;

14
.storybook/preview.ts Normal file
View file

@ -0,0 +1,14 @@
import type { Preview } from '@storybook/react-vite'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View file

@ -1,17 +0,0 @@
steps:
- name: build_library
image: node:alpine
volumes:
- /tmp/woodpecker/cache/uikernel/core/node_modules:/woodpecker/src/code.zeptotech.net/UIKernel/Core/node_modules
- /tmp/woodpecker/cache/uikernel/core/.yarn/cache:/woodpecker/src/code.zeptotech.net/UIKernel/Core/.yarn/cache
secrets:
- FORGE_TOKEN
commands:
- corepack enable
- yarn install
- yarn build
- ./.woodpecker/yarn_auth.sh
- yarn npm publish
when:
- event: tag
ref: refs/tags/v*

View file

@ -1,6 +0,0 @@
#!/bin/sh
echo "npmRegistries:
//code.zeptotech.net/api/packages/UIKernel/npm/:
npmAlwaysAuth: true
npmAuthToken: \"$FORGE_TOKEN\"" >> ./.yarnrc.yml

View file

@ -1,635 +0,0 @@
import React, {useState} from "react";
import {Checkbox} from "../src/Components/Forms/Checkbox";
import {Radio} from "../src/Components/Forms/Radio";
import {AirTrafficControl, Basket, FloppyDisk, House, 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 {Float} from "../src/Components/Floating/Float";
import {Tooltip} from "../src/Components/Floating/Tooltip";
import {DatepickerInput} from "../src/Components/Forms/DatepickerInput";
import {TimepickerInput} from "../src/Components/Forms/TimepickerInput";
import {Select} from "../src/Components/Select/Select";
import {SpinningLoader} from "../src/Components/Loaders/SpinningLoader";
import {ListLoader} from "../src/Components/Loaders/ListLoader";
import {GenericLoader} from "../src/Components/Loaders/GenericLoader";
import {MainMenu} from "../src/Components/Menus/MainMenu";
import {SubmenuFloat} from "../src/Components/Menus/SubmenuFloat";
import {Submenu} from "../src/Components/Menus/Submenu";
import {SubmenuItem, SubmenuItemSubmenu} from "../src/Components/Menus/SubmenuItem";
import {MainMenuItemSubmenu, MainMenuLink} from "../src/Components/Menus/MainMenuItem";
import {AppItem, AppLink, AppsMenu} from "../src/Components/Menus/AppsMenu";
import {Application} from "../src/Application/Application";
import {Outlet} from "react-router-dom";
import {ToggleSwitch} from "../src/Components/Forms/ToggleSwitch";
import {Step, Steps} from "../src/Components/Steps/Steps";
import {AsyncPaginate, AutoPaginate, Paginate} from "../src/Components/Pagination/Paginate";
import {useCallableCurtain, useCurtains} from "../src/Components/Curtains/Curtains";
import {Subapp, useCallableSubapp, useSubapps} from "../src/Components/Subapps/Subapps";
import {DemoSubapp} from "./DemoSubapp";
import {DemoCurtain} from "./DemoCurtain";
import {DemoModal} from "./DemoModal";
import {useCallableModal} from "../src/Components/Modals/Modals";
import {ModalType} from "../src/Components/Modals/ModalsTypes";
import {Buttons} from "../src/Components/Buttons/Buttons";
import {useNotify} from "../src/Components/Notifications/Notifications";
import {Notification, NotificationType} from "../src/Components/Notifications/Notification";
import {Box} from "../src/Components/Box";
import {Await, useAsync} from "../src/Async";
import {NotifyErrorsBoundary} from "../src/Components/Errors/NotifyErrorsBoundary";
import {DemoFailingComponent, DemoResetComponent} from "./DemoFailingComponent";
import {Tip} from "../src/Components/Tips/Tip";
import {useKernelContext} from "../src/KernelGlobalContext";
import {KernelContext} from "./DemoKernelContext";
export function DemoApp()
{
const curtains = useCurtains();
const subapps = useSubapps();
// Easy curtain.
const easyCurtain = useCallableCurtain(<DemoCurtain />);
// Easy subapp.
const easySubapp = useCallableSubapp(<DemoSubapp />);
// Easy modal.
const easyModal = useCallableModal((type: ModalType = ModalType.NONE) => <DemoModal type={type} />);
const notify = useNotify();
const [datetime, setDatetime] = useState(null);
const [selected, setSelected] = useState(null);
const [anotherSelected, setAnotherSelected] = useState(null);
const [page, setPage] = useState(11);
const [asyncChange, setAsyncChange] = useState(0);
const [anotherChange, setAnotherChange] = useState(0);
const [asyncData] = useAsync<string>(() => new Promise((resolve, reject) => {
setTimeout(() => {
resolve("async data (" + Math.random() + ")");
}, 2000);
}), [asyncChange]);
const [failingComponentsCount, setFailingComponentsCount] = useState(0);
const [kernelContext, setKernelContext] = useKernelContext(KernelContext);
return (
<Application>
<MainMenu>
<MainMenuLink to={"/"}><House /> Home</MainMenuLink>
<MainMenuLink to={"/test"}><AirTrafficControl /> Test</MainMenuLink>
<MainMenuItemSubmenu submenu={
<Submenu>
<SubmenuItem>Test 1</SubmenuItem>
<SubmenuItem>Test 2</SubmenuItem>
<SubmenuItemSubmenu submenu={
<Submenu>
<SubmenuItem>Test A</SubmenuItem>
<SubmenuItem>Test B</SubmenuItem>
<SubmenuItemSubmenu submenu={
<Submenu>
<SubmenuItem href={"#first-last-choice"}>First last choice</SubmenuItem>
<SubmenuItem>Another last choice</SubmenuItem>
</Submenu>
}><Basket /> Submenu in submenu</SubmenuItemSubmenu>
</Submenu>
}><Basket /> Submenu</SubmenuItemSubmenu>
</Submenu>
}>
<Basket /> Submenu
</MainMenuItemSubmenu>
</MainMenu>
<h1>KernelUI</h1>
<h2>Headings</h2>
<Box>
<h1>Demo app</h1>
<h2>Second title</h2>
<h3>Third title</h3>
<h4>Fourth title</h4>
<h5>Fifth title</h5>
<h6>Sixth title</h6>
</Box>
<h2>Buttons</h2>
<button type={"button"}>A cool button</button>
<a className={"button"} href={"#"}>A link button</a>
<button type={"button"} className={"flat"}>A flat button</button>
<button type={"button"} className={"validation"}><FloppyDisk /> A validation button</button>
<button type={"button"} className={"cancel"}><XCircle /> A cancellation button</button>
<button type={"button"} className={"delete"}><TrashSimple /> A deletion button</button>
<h2>Forms</h2>
<form>
<label>
Text label <RequiredField/>
<input type={"text"} placeholder={"Normal demo text"} required={true}/>
</label>
<label>
Textarea label <RequiredField/>
<textarea placeholder={"A normal textarea."} required={true}></textarea>
</label>
<PasswordInput>
Test password
</PasswordInput>
<label>
Disabled input
<input type={"text"} name={"disabled"} value={"fixed value"} disabled={true} />
</label>
<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>
<ToggleSwitch>Toggle switch demo</ToggleSwitch>
<Radio name={"radio-test"}>Radio box test</Radio>
<Radio name={"radio-test"}>Radio box test</Radio>
<Tip>
A tip component, very useful in forms which require some explanations.
</Tip>
<Select options={{
"a": "AAAAAA",
"b": "BBBBBB",
"c": "CCCCCC",
"d": "DDDDDD",
"e": "EEEEEE",
"f": "FFFFFF",
"g": "GGGGGG",
"h": "HHHHHH",
"i": "IIIIII",
"j": "JJJJJJ",
"k": "KKKKKK",
"l": "LLLLLL",
"m": "MMMMMM",
"n": "NNNNNN",
}} value={selected} onChange={setSelected} selectibleMaxCount={3} placeholder={"Simple test"}>
Simple select test
</Select>
<Select value={anotherSelected} onChange={setAnotherSelected} options={{
"test": "test",
"foo": "foo",
"bar": "bar",
}} required={true}>
At least one selected element
</Select>
<Buttons placement={"center"}>
<button>Validation test</button>
</Buttons>
</form>
<h2>HTML</h2>
<div>
<a href={"#"}>Link test</a>
</div>
<p>
<strong>Lorem ipsum</strong> dolor <code>sit amet</code>, <em>consectetur</em> adipiscing elit <a
href={"https://aleph.land"} target={"_blank"}>aleph</a>. Donec accumsan
pulvinar felis, vitae eleifend augue lacinia tempus. Integer nec iaculis ante. Duis a quam urna. Nullam
tincidunt rutrum felis, a efficitur enim facilisis sit amet. Quisque dictum semper sagittis. Maecenas in orci
hendrerit, tempor nunc non, tempus mi. Praesent blandit varius rutrum. Nullam quis mauris eros. Vestibulum
commodo libero sed pellentesque pharetra. Donec eget fringilla ante. Aliquam id leo massa. Duis dictum nunc ut
dolor iaculis malesuada. Nulla elementum justo a sem eleifend finibus. Phasellus bibendum elit nibh, at tempor
odio efficitur id.
</p>
<h2>Steps</h2>
<div className={"steps-counter"}>
<h3 className={"step"}>Step one</h3>
<h3 className={"step"}>Step two</h3>
<h3 className={"step"}>Step three</h3>
</div>
<h2>Lists</h2>
<ul>
<li>One</li>
<li>Two</li>
<li>Three</li>
<li>Four</li>
</ul>
<ol>
<li>One</li>
<li>Two</li>
<li>Three</li>
<li>Four</li>
</ol>
<h2>Tables</h2>
<table>
<thead>
<tr>
<th>Column 1</th>
<th>Column 2</th>
<th colSpan={2}>Column 3</th>
</tr>
<tr>
<th>A</th>
<th>B</th>
<th>C</th>
<th>D</th>
</tr>
</thead>
<tbody>
<tr>
<td>Lorem</td>
<td>Ipsum</td>
<td>Dolor</td>
<td>Amet</td>
</tr>
<tr>
<td>Foo</td>
<td>Bar</td>
<td>Baz</td>
<td>John</td>
</tr>
<tr>
<td>Alice</td>
<td>Bob</td>
<td></td>
<td>Jack</td>
</tr>
</tbody>
</table>
<h2>Cards</h2>
<Card>
<h3>Card title</h3>
<p>
Donec lobortis quam sapien, et efficitur dolor laoreet ut. Ut pretium, lacus at bibendum rutrum, nibh nibh
scelerisque nisi, nec semper dolor turpis sed tortor. Nulla massa sapien, accumsan id vestibulum et, elementum
eget metus. Morbi quis bibendum purus. Nunc at fermentum tortor. Quisque viverra diam in sem auctor blandit.
Vestibulum dignissim bibendum nunc, non tristique quam sollicitudin eu. Ut feugiat lectus tellus, tempus
viverra sapien aliquet vel. Morbi ac est mauris. Praesent facilisis ut tellus at cursus. Aenean placerat nulla
non mi vulputate hendrerit. Praesent fermentum dui eu gravida pharetra. Quisque rhoncus, magna non congue
egestas, leo lorem malesuada felis, eu imperdiet orci magna ultrices est.
</p>
<Buttons>
<button type={"button"}>Button position ?</button>
<button type={"button"}>Button position ?</button>
</Buttons>
</Card>
<Card>
<p>Another small card</p>
</Card>
<h2>Popovers</h2>
<Float mode={"hover"} content={"Do you see me?"}>
<button type={"button"}>Hover me!</button>
</Float>
<Float mode={"focus"} content={<>I am <strong>focused</strong></>}>
<button>Focus me!</button>
</Float>
<Float mode={"click"} content={(
<div>
You can add complex (clickable) content in me.
<button type={"button"}>OK</button>
</div>
)}>
<button>Click me!</button>
</Float>
<Float mode={"always"} content={"I am always shown."} floatingOptions={{placement: "top"}}>
<button>Why always me?</button>
</Float>
<div>
<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>
)}
</Float>
</div>
<h2>Tooltips</h2>
<Card>
This is a very simple <Tooltip content={"I am a beautiful simple tooltip."}><a>tooltip</a></Tooltip>.
</Card>
<h2>Loaders</h2>
<h3>Simple loaders</h3>
<Card>
<SpinningLoader inline={true} />
<ListLoader/>
</Card>
<h3>Generic loader</h3>
<GenericLoader>
<Card>
Sample content.
</Card>
</GenericLoader>
<h2>Menus</h2>
<SubmenuFloat submenu={
<Submenu>
<SubmenuItem>Test 1</SubmenuItem>
<SubmenuItem>Test 2</SubmenuItem>
<SubmenuItemSubmenu submenu={
<Submenu>
<SubmenuItem>Test A</SubmenuItem>
<SubmenuItem><AirTrafficControl /> Test B</SubmenuItem>
</Submenu>
}>
<Basket /> Submenu
</SubmenuItemSubmenu>
</Submenu>
} floatingOptions={{placement: "right-start"}}>
<button>Submenu on a button</button>
</SubmenuFloat>
<h2>App selectors</h2>
<AppsMenu>
<AppLink to={"/"}>
<House />
Home
</AppLink>
<AppLink to={"/test"}>
<AirTrafficControl />
Test link
</AppLink>
<AppItem>
<Basket />
Test 3
</AppItem>
</AppsMenu>
<Outlet />
<h2>App steps</h2>
<h3>Basic</h3>
<Steps>
<Step stepKey={"abc"}>
<Card>
<h4>First step</h4>
ABC STEP
</Card>
</Step>
<Step stepKey={"def"} stepTitle={"Title"}>
<Card>
<h4>Big content</h4>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam id dignissim ligula, ut tempus sem. Sed
faucibus tincidunt ante vel iaculis. Duis hendrerit, orci eu gravida interdum, nulla lacus congue augue,
nec efficitur diam dui sollicitudin eros. Donec lacus lectus, aliquam nec feugiat non, gravida ac est.
Suspendisse feugiat justo quis dui vehicula, sed auctor est mollis. Sed hendrerit nisi non lectus lacinia,
id posuere dolor dignissim. Quisque commodo mi sit amet quam tincidunt auctor. Ut sit amet scelerisque
sem. Nulla rhoncus, orci vitae cursus ullamcorper, ex odio rhoncus enim, et blandit elit libero quis est.
Suspendisse lectus nunc, gravida sit amet vulputate eget, porta ac odio.
</p>
<p>
Mauris egestas bibendum facilisis. Maecenas accumsan lorem arcu, ut faucibus dui euismod et. Nulla et
dignissim est, interdum luctus diam. Aliquam condimentum ex augue, id porttitor enim vestibulum quis.
Vivamus sed convallis leo. Duis finibus, ipsum sed condimentum viverra, ipsum sapien congue nunc, sed
fermentum metus ante quis ligula. Fusce eleifend ante in leo molestie, at suscipit metus cursus. Class
aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed laoreet justo ac
lacus porta, sed ultricies est mattis. Integer finibus purus metus, quis posuere risus suscipit suscipit.
Nulla facilisi. Duis tincidunt vitae enim eu sagittis.
</p>
<p>
Donec rutrum tellus vitae vehicula tempor. Sed porta tempus leo, vel aliquam risus scelerisque nec.
Pellentesque diam nibh, ultrices in rhoncus ut, rhoncus et ligula. Duis pellentesque diam purus, ut
scelerisque turpis condimentum sit amet. Sed sit amet efficitur tortor, vitae aliquet quam. Nullam
placerat dui eu sapien condimentum, placerat convallis sapien imperdiet. Suspendisse vitae laoreet ex.
Etiam quis rhoncus ante. Ut egestas eget ipsum ultrices tempus. Vivamus non odio non nisl aliquet rhoncus.
Ut et nisl placerat, interdum turpis a, condimentum mauris. In laoreet lobortis justo. Maecenas vehicula
magna non libero posuere rutrum. Praesent eget lectus feugiat dui pellentesque vehicula a sed felis.
Curabitur nunc orci, vehicula non gravida sed, suscipit sed diam. Nam semper, dui eu volutpat vulputate,
metus mauris congue lectus, ultrices sollicitudin eros felis ac erat.
</p>
<p>
Proin et rhoncus purus. Etiam nulla libero, dictum sed quam lacinia, consequat euismod ipsum. Donec quis
tristique metus. Cras vitae pretium massa. Etiam laoreet, eros in rhoncus ultrices, nibh nibh bibendum
diam, nec ultricies mi ante non ex. Pellentesque eget mattis dolor, eget pulvinar lorem. Nullam ante
dolor, ultricies et malesuada in, efficitur sit amet diam. Duis ligula augue, vestibulum sit amet ligula
ac, vestibulum tincidunt eros. Nullam commodo euismod vulputate. Morbi varius accumsan diam eu
pellentesque. Nunc vehicula pretium risus dapibus cursus. Mauris sit amet est at ipsum scelerisque
lobortis. Aenean eget quam sit amet arcu mattis interdum in at neque.
</p>
<p>
Mauris efficitur, enim pellentesque maximus faucibus, nibh enim gravida urna, quis dignissim turpis velit
quis nibh. Mauris eget vestibulum tellus. Duis mollis, ante in egestas lacinia, felis massa rutrum ante,
vel placerat lectus neque in purus. Fusce sodales nunc vel ligula mollis tincidunt. Sed ac viverra ligula.
Vestibulum ut velit sit amet ipsum cursus posuere in nec lacus. Praesent vel odio pellentesque,
ullamcorper metus in, accumsan dolor. Donec vel mi ultrices, interdum arcu vel, pellentesque nunc.
</p>
</Card>
</Step>
<Step stepKey={"ghi"}>
<Card>
<h4>Third step</h4>
GHI STEP
</Card>
</Step>
</Steps>
<h2>Pagination</h2>
<h3>Normal pagination</h3>
<Paginate onChange={setPage} count={72} page={page}>
<Card>Page {page}</Card>
</Paginate>
<h3>Auto pagination</h3>
<AutoPaginate count={55}>
{(page) => (
<Card>Page {page}</Card>
)}
</AutoPaginate>
<h3>Async pagination</h3>
<AsyncPaginate count={async () => { return 72; }} getData={async () => (["a", Math.random(), "c"])}>
{(data) => (
<>
{
data.map((value, index) => (
<div key={index}>{value}</div>
))
}
</>
)}
</AsyncPaginate>
<h2>Curtains & co</h2>
<h3>Curtains</h3>
<Card>
<button onClick={() => {
curtains.open(<DemoCurtain />);
}}>Open a curtain</button>
<button onClick={easyCurtain}>Easy with callable curtain</button>
</Card>
<h3>Subapps</h3>
<Card>
<button onClick={() => {
subapps.open(
<Subapp title={"Title test"}>
<p>A test content.</p>
<Buttons placement={"center"}>
<Float mode={"click"} content={"Test content."}>
<button>A button with floating content</button>
</Float>
</Buttons>
</Subapp>
)
}}>Open a subapp
</button>
<button onClick={() => { subapps.open(<DemoSubapp />) }}>
Complex subapp with component
</button>
<button onClick={easySubapp}>
Easy with callable subapp
</button>
<button onClick={() => {
subapps.open(
<Subapp title={"Very long subapp"}>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
<Card><p>A test content.</p></Card>
</Subapp>
)
}}>A long subapp
</button>
</Card>
<h3>Modals</h3>
<Card>
<button onClick={() => easyModal(ModalType.INFO)}>Open an info modal</button>
<button className={"warning"} onClick={() => easyModal(ModalType.WARNING)}>Open a warning modal</button>
<button className={"flat"} onClick={() => easyModal()}>Open a simple modal</button>
</Card>
<h2>Notifications</h2>
<Card>
<button onClick={() => { notify(<Notification type={NotificationType.INFO}>Test notification</Notification>); }}>Information notification</button>
<button className={"success"} onClick={() => { notify(<Notification type={NotificationType.SUCCESS}>Test notification</Notification>); }}>Success notification</button>
<button className={"warning"} onClick={() => { notify(<Notification type={NotificationType.WARNING}>Test notification</Notification>); }}>Warning notification</button>
<button className={"error"} onClick={() => { notify(<Notification type={NotificationType.ERROR}>Test notification</Notification>); }}>Error notification</button>
<button className={"flat"} onClick={() => { notify(<Notification>Test notification</Notification>); }}>Generic notification</button>
</Card>
<h2>Async</h2>
<Card>
Async data test:
<Await async={asyncData} fallback={<SpinningLoader />}>
{(data) => (
<p>Data: {data}</p>
)}
</Await>
<button type={"button"} onClick={() => setAsyncChange(asyncChange + 1)}>Change async deps</button>
<button type={"button"} onClick={() => setAnotherChange(anotherChange + 1)}>Change something else</button>
</Card>
<h2>Error boundaries</h2>
<Card>
<button type={"button"} className={"error"}
onClick={() => setFailingComponentsCount(failingComponentsCount + 1)}>Do something dangerous</button>
<NotifyErrorsBoundary fallback={<DemoResetComponent />}>
{
[...Array(failingComponentsCount)].map(() => <DemoFailingComponent />)
}
</NotifyErrorsBoundary>
</Card>
<h2>
Global states
</h2>
<Card>
<label>
Kernel context data
<input type={"text"} name={"kernel-context-data"}
value={kernelContext}
onChange={(event) => setKernelContext(event.currentTarget.value)} />
</label>
<Buttons placement={"center"}>
<button type={"button"} onClick={easySubapp}>Open demo subapp to see if it works</button>
</Buttons>
</Card>
</Application>
);
}

View file

@ -1,15 +0,0 @@
import React from "react";
import {useCurtain} from "../src/Components/Curtains/CurtainInstance";
import {X} from "@phosphor-icons/react";
/**
* Demo curtain component.
*/
export function DemoCurtain()
{
const {close} = useCurtain();
return (
<button className={"close"} onClick={close}><X/> Close the curtain</button>
);
}

View file

@ -1,26 +0,0 @@
import React from "react";
import {useErrorBoundary} from "react-error-boundary";
/**
* A simple demo failing component.
*/
export function DemoFailingComponent()
{
throw new Error("Proudly thrown error.");
return (<p>I will never be shown...</p>);
}
/**
* A simple demo reset component.
*/
export function DemoResetComponent()
{
// Get error boundary.
const errorBoundary = useErrorBoundary();
return (
<button type={"button"} onClick={() => {
errorBoundary.resetBoundary();
}}>Reset to try again!</button>
);
}

View file

@ -1,6 +0,0 @@
import {createKernelContext} from "../src/KernelGlobalContext";
/**
* Create kernel context.
*/
export const KernelContext = createKernelContext("");

View file

@ -1,19 +0,0 @@
import React from "react";
import {Modal, useModal} from "../src/Components/Modals/Modals";
import {ModalType} from "../src/Components/Modals/ModalsTypes";
import {Buttons} from "../src/Components/Buttons/Buttons";
export function DemoModal({type}: { type: ModalType; })
{
const {close} = useModal();
return (
<Modal type={type} title={"Modal title"}>
Modal test content
<Buttons>
<button onClick={close}>OK</button>
</Buttons>
</Modal>
);
}

View file

@ -1,31 +0,0 @@
import React from "react";
import {Subapp, useSubapp} from "../src/Components/Subapps/Subapps";
import {Card} from "../src/Components/Card";
import {useKernelContext} from "../src/KernelGlobalContext";
import {KernelContext} from "./DemoKernelContext";
/**
* A demo Subapp component.
*/
export function DemoSubapp()
{
// Get subapp close function.
const {uuid, close} = useSubapp();
// Get kernel context data.
const [kernelContext] = useKernelContext(KernelContext);
return (
<Subapp title={"My complex subapp"}>
<Card>
<p>This is a complex subapp.</p>
<p>UUID : <code>{uuid}</code></p>
{kernelContext && <p><strong>Kernel context data</strong>: <code>{kernelContext}</code></p>}
<button onClick={close}>Close the subapp</button>
</Card>
</Subapp>
);
}

View file

@ -1,14 +0,0 @@
import React from "react";
import {Card} from "../src/Components/Card";
/**
* Navigation test component.
*/
export function NavTest()
{
return (
<Card>
This is a navigation test.
</Card>
)
}

View file

@ -1,37 +0,0 @@
import "../index";
import React from "react";
import {createRoot} from "react-dom/client";
import {DemoApp} from "./DemoApp";
import {createBrowserRouter} from "react-router-dom";
import {Kernel} from "../src/Application/Kernel";
import {NavTest} from "./NavTest";
import {Avocado} from "@phosphor-icons/react";
import {ApplicationError} from "../src/Application/ApplicationError";
// Router initialization.
const router = createBrowserRouter([
{
path: "/",
element: <DemoApp />,
children: [
{
path: "test",
element: <NavTest />,
}
],
errorElement: <ApplicationError />,
}
])
document.addEventListener("DOMContentLoaded", () => {
const demoApp = document.getElementById("demo-app");
const root = createRoot(demoApp);
root.render(<Kernel router={router} footer={
<footer>
<Avocado weight={"duotone"} size={32} />
<div>Kernel</div>
</footer>
} />);
});

26
eslint.config.js Normal file
View file

@ -0,0 +1,26 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
], storybook.configs["flat/recommended"]);

View file

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>UIKernel - Demo</title>
<script type="module" src="demo/demo.tsx"></script>
</head>
<body id="demo-app">
</body>
</html>

View file

@ -1,53 +0,0 @@
import "./src/styles/main.less";
export * from "./src/Application/Application";
export * from "./src/Application/ApplicationError";
export * from "./src/Application/Kernel";
export * from "./src/Components/Box";
export * from "./src/Components/Card";
export * from "./src/Components/Buttons/Buttons";
export * from "./src/Components/Curtains/Curtains";
export {useCurtain} from "./src/Components/Curtains/CurtainInstance";
export type {CurtainContextState} from "./src/Components/Curtains/CurtainInstance";
export * from "./src/Components/Dates/Calendar";
export * from "./src/Components/Dates/Datepicker";
export * from "./src/Components/Errors/NotifyErrorsBoundary";
export * from "./src/Components/Floating/Float";
export * from "./src/Components/Floating/Tooltip";
export * from "./src/Components/Forms/Checkbox";
export * from "./src/Components/Forms/CustomValidationRule";
export * from "./src/Components/Forms/DatepickerInput";
export * from "./src/Components/Forms/PasswordInput";
export * from "./src/Components/Forms/Radio";
export * from "./src/Components/Forms/RequiredField";
export * from "./src/Components/Forms/TimepickerInput";
export * from "./src/Components/Forms/ToggleSwitch";
export * from "./src/Components/Loaders/GenericLoader";
export * from "./src/Components/Loaders/ListLoader";
export * from "./src/Components/Loaders/SpinningLoader";
export * from "./src/Components/Menus/AppsMenu";
export * from "./src/Components/Menus/MainMenu";
export * from "./src/Components/Menus/MainMenuItem";
export * from "./src/Components/Menus/Submenu";
export * from "./src/Components/Menus/SubmenuFloat";
export * from "./src/Components/Menus/SubmenuItem";
export * from "./src/Components/Modals/Modals";
export * from "./src/Components/Modals/ModalsTypes";
export * from "./src/Components/Notifications/Notification";
export * from "./src/Components/Notifications/Notifications";
export * from "./src/Components/Pagination/Paginate";
export * from "./src/Components/Pagination/Pagination";
export * from "./src/Components/Select/OptionsSuggestions";
export * from "./src/Components/Select/Select";
export * from "./src/Components/Select/SimpleSuggestions";
export * from "./src/Components/Select/Suggestible";
export * from "./src/Components/Steps/Steps";
export * from "./src/Components/Steps/StepsContext";
export * from "./src/Components/Subapps/Subapps";
export * from "./src/Components/Tips/Tip";
export * from "./src/Async";
export * from "./src/GlobalState";
export * from "./src/KernelGlobalContext";
export * from "./src/Utils";

View file

@ -4,47 +4,46 @@
"description": "Kernel UI Core.", "description": "Kernel UI Core.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build" "build": "tsc && vite build",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
}, },
"type": "module", "type": "module",
"source": "index.ts", "source": "index.ts",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
"main": "lib/index.js", "main": "lib/index.js",
"files": [ "files": [
"lib/**/*" "lib/**/*",
"README.md",
"LICENSE"
], ],
"publishConfig": { "publishConfig": {
"@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/" "@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/"
}, },
"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",
"react-error-boundary": "^4.0.13",
"react-merge-refs": "^2.1.1",
"uuid": "^10.0.0"
},
"devDependencies": { "devDependencies": {
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.10",
"@types/node": "^22.7.4", "@storybook/addon-docs": "^9.1.5",
"@types/react": "^18.3.12", "@storybook/react-vite": "^9.1.5",
"@types/react-dom": "^18.3.1", "@types/node": "^24.3.1",
"@types/uuid": "^10", "@types/react": "^19.1.12",
"@vitejs/plugin-react": "^4.3.4", "@types/react-dom": "^19.1.9",
"less": "^4.2.0", "@vitejs/plugin-react": "^5.0.2",
"react": "^18.3.1", "eslint": "^9.35.0",
"react-dom": "^18.3.1", "eslint-plugin-storybook": "^9.1.5",
"react-router-dom": "^7.0.1", "prettier": "^3.6.2",
"typescript": "^5.6.2", "react": "^19.1.1",
"vite": "^6.0.1", "react-dom": "^19.1.1",
"vite-plugin-dts": "^4.3.0" "react-router-dom": "^7.8.2",
"storybook": "^9.1.5",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vite-plugin-dts": "^4.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.10",
"react": "^18.3.1", "react": "^19.1.1",
"react-dom": "^18.3.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.0.1" "react-router-dom": "^7.8.2"
}, },
"packageManager": "yarn@4.5.3" "packageManager": "yarn@4.9.4"
} }

View file

@ -1,18 +0,0 @@
import React from "react";
import {ApplicationError, ApplicationErrorBoundary} from "./ApplicationError";
/**
* Main Kernel UI application.
*/
export function Application({errorElement, children}: React.PropsWithChildren<{
errorElement?: React.ReactNode;
}>)
{
return (
<ApplicationErrorBoundary errorElement={errorElement ?? <ApplicationError />}>
<main className={"app"}>
{children}
</main>
</ApplicationErrorBoundary>
);
}

View file

@ -1,94 +0,0 @@
import React, {useContext, useState} from "react";
import {ArrowsClockwise, Bug, BugDroid} from "@phosphor-icons/react";
import {useRouteError} from "react-router-dom";
/**
* Application error context.
*/
const ApplicationErrorContext = React.createContext<Error>(undefined);
/**
* Get current error from context or router.
*/
export function useApplicationError(): Error
{
// Get error from context or router.
const error = useContext(ApplicationErrorContext);
const routeError = useRouteError() as Error;
return error ?? routeError;
}
/**
* Application error component.
*/
export function ApplicationError({children}: {
children?: (error: Error) => React.ReactElement;
})
{
// Get error from context.
const error = useApplicationError();
// Show details state.
const [showDetails, setShowDetails] = useState(false);
return (
<main className={"error"}>
{children ? children(error) : (
<>
<Bug size={64} weight={"duotone"} />
<h1>Error</h1>
<hr />
<h2>{error.name}</h2>
<p>An unexpected error happened and the application was forced to quit.</p>
<pre className={"error"}>{error.message}</pre>
<button onClick={() => window.location.reload()}><ArrowsClockwise size={20} /> Restart application</button>
<div className={"details"}>
<button onClick={() => setShowDetails(!showDetails)}><BugDroid size={18} /> {showDetails ? "Hide" : "Show"} details</button>
{ // Show details if required.
showDetails && (
<pre>
{error.stack}
</pre>
)
}
</div>
</>
)}
</main>
);
}
/**
* Error boundary component for the application.
*/
export class ApplicationErrorBoundary extends React.Component<React.PropsWithChildren<{
errorElement: React.ReactNode;
}>, {
error: Error;
}>
{
static getDerivedStateFromError(error: Error)
{
return { error: error };
}
render()
{
if (this.state?.error)
// An error happened, showing the application error content.
return (
<ApplicationErrorContext.Provider value={this.state?.error}>
{this.props.errorElement}
</ApplicationErrorContext.Provider>
);
return this.props.children;
}
}

View file

@ -1,33 +0,0 @@
import { IconContext } from "@phosphor-icons/react";
import React from "react";
import {createBrowserRouter, RouterProvider} from "react-router-dom";
import {CurtainsProvider} from "../Components/Curtains/Curtains";
import {NotificationsProvider} from "../Components/Notifications/Notifications";
import {KernelGlobalContextProvider} from "../KernelGlobalContext";
/**
* Main Kernel UI app component which initializes everything.
*/
export function Kernel({header, footer, router}: {
header?: React.ReactNode;
footer?: React.ReactNode;
router: ReturnType<typeof createBrowserRouter>;
})
{
return (
<KernelGlobalContextProvider>
<IconContext.Provider value={{
size: "1em",
weight: "bold",
}}>
<NotificationsProvider>
<CurtainsProvider>
{header}
<RouterProvider router={router} />
{footer}
</CurtainsProvider>
</NotificationsProvider>
</IconContext.Provider>
</KernelGlobalContextProvider>
);
}

View file

@ -1,158 +0,0 @@
import React, {useEffect, useMemo, useState} from "react";
/**
* A type that can be returned by a promise or as is.
*/
export type Promisable<T> = T|Promise<T>;
/**
* Asynchronous data state.
*/
interface AsyncState<T>
{
/**
* Determine if we are waiting for the promise result or not.
*/
pending: boolean;
/**
* The promise which is retrieved (or has retrieved) data.
*/
promise: Promisable<T>;
/**
* Error thrown by the promise.
*/
error: Error;
/**
* The promise result.
*/
data: T;
}
/**
* A promise production function.
*/
export type PromiseFn<T> = () => Promise<T>;
/**
* React hook for promise result retrieval.
* @param promise The promise or a function which produces a promise.
* @param deps When one of the `deps` change, it will wait for the promise again.
*/
export function useAsync<T>(promise: Promisable<T>|PromiseFn<T>, deps: any[] = []): [AsyncState<T>, React.Dispatch<T>]
{
// Get the actual promise from the function if there is one.
promise = useMemo(() => {
if ((promise as PromiseFn<T>)?.call)
return (promise as PromiseFn<T>)();
else if (promise instanceof Promise)
return Promise.race([promise as Promise<T>]);
else
return promise;
}, deps);
// The async state.
const [state, setState] = useState<AsyncState<T>>({
pending: promise instanceof Promise,
promise: promise as Promisable<T>,
error: undefined,
data: promise instanceof Promise ? undefined : promise as T,
});
/**
* Partial update of an async state.
* @param stateUpdate A partial update object.
*/
const updateState = (stateUpdate: Partial<AsyncState<T>>) => {
// Copy the original state and apply the partial state update.
setState(Object.assign({}, state, stateUpdate));
};
// Reconfigure the promise when any deps have changed.
useEffect(() => {
if (!(promise instanceof Promise))
{ // If it's not a promise, there is nothing to wait for.
updateState({
pending: false,
promise: promise as Promisable<T>,
error: undefined,
data: promise as T,
});
return;
}
promise.then((result) => {
// When there is a result, disable pending state and set retrieved data, without error.
updateState({
pending: false,
error: undefined,
data: result,
})
}).catch((error) => {
// An error happened, disable pending state, reset data, and set the error.
updateState({
pending: false,
error: error,
data: undefined,
})
});
// Promise is ready: reset the state to pending with the configured promise, without data and error.
updateState({
pending: true,
promise: promise,
error: undefined,
data: undefined,
});
}, deps);
// Return the current async state and a dispatch data function.
return [state, (data: T) => {
updateState({data: data});
}];
}
/**
* Wait for the promise to be fulfilled to render the children.
* @param async The async state.
* @param children Renderer function of the children, takes promised data as argument.
* @param fallback Content shown when the promise is not fulfilled yet.
* @constructor
*/
export function Await<T>({ async, children, fallback }: {
async: AsyncState<T>;
children: (async: T) => React.ReactElement;
fallback?: React.ReactElement;
})
{
// Still waiting for the promised data, showing fallback content.
if (async.pending) return fallback ?? <></>;
// An error happened, throwing it.
if (async.error) throw async.error;
// Promise is fulfilled, rendering the children with result data.
return children(async.data);
}
/**
* Easy async component with fallback and fulfilled children render.
* @param promise The promise.
* @param children Renderer function of the children, takes promised data as argument.
* @param fallback Content shown when the promise is not fulfilled yet.
* @constructor
*/
export function Async<T>({promise, fallback, children}: {
promise: Promisable<T>|PromiseFn<T>;
children: (async: T) => React.ReactElement;
fallback?: React.ReactElement;
})
{
// Get async data from given promise.
const [async] = useAsync(promise, [promise]);
return (
// Wait for promised data.
<Await async={async} fallback={fallback ?? undefined}>
{children}
</Await>
)
}

View file

@ -1,14 +0,0 @@
import React, {PropsWithChildren} from "react";
import {classes} from "../Utils";
/**
* Content box generic component.
*/
export function Box({children, className, ...props}: PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>): React.ReactElement
{
return (
<div className={classes("box", className)} {...props}>
{children}
</div>
);
}

View file

@ -1,16 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
export function Buttons({placement, children}: React.PropsWithChildren<{
placement?: "right"|"left"|"center";
}>)
{
// Default placement: right.
placement = placement ?? "right";
return (
<div className={classes("buttons", placement)}>
{children}
</div>
);
}

View file

@ -1,14 +0,0 @@
import React, {PropsWithChildren} from "react";
import {classes} from "../Utils";
/**
* Content card component.
*/
export function Card({children, className, ...props}: PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>): React.ReactElement
{
return (
<div className={classes("card", className)} {...props}>
{children}
</div>
);
}

View file

@ -1,79 +0,0 @@
import React, {useCallback, useContext, useMemo, useRef} from "react";
import {CurtainUuidType, useCurtains} from "./Curtains";
import {classes} from "../../Utils";
/**
* Current curtain data and functions.
*/
export interface CurtainContextState
{
/**
* Curtain UUID.
*/
uuid: CurtainUuidType;
/**
* Close the curtain.
*/
close: () => void;
/**
* True if the curtain is closed (while transitioning out).
*/
closed: boolean;
}
/**
* Current curtain context.
*/
const CurtainContext = React.createContext<CurtainContextState>({
// Empty values.
uuid: "",
close: () => {},
closed: false,
});
/**
* Hook to access current curtain data and functions.
*/
export function useCurtain(): CurtainContextState
{
return useContext(CurtainContext);
}
/**
* Component of an opened curtain instance.
*/
export function CurtainInstance({uuid, children}: React.PropsWithChildren<{
/**
* Curtain UUID.
*/
uuid: string;
}>)
{
// Get close curtain function.
const {close, isClosed} = useCurtains();
// Initialize close curtain function.
const closeCurtain = useRef<() => void>();
closeCurtain.current = useCallback(() => {
// Close the current curtain.
close(uuid);
}, [uuid, close]);
// Initialize context state from action functions.
const contextState = useMemo(() => ({
uuid: uuid,
close: () => closeCurtain.current(),
closed: isClosed(uuid),
}), [uuid, closeCurtain, isClosed]);
return (
<CurtainContext.Provider value={contextState}>
<div className={classes("curtain", isClosed(uuid) ? "closed" : undefined)}>
{children}
</div>
</CurtainContext.Provider>
);
}

View file

@ -1,207 +0,0 @@
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from "react";
import ReactDOM from "react-dom";
import {v4 as uuidv4} from "uuid";
import {CurtainInstance} from "./CurtainInstance";
/**
* Curtain UUID type.
*/
export type CurtainUuidType = string;
/**
* The function that opens a curtain.
*/
export type OpenCurtainFunction = (content: React.ReactNode) => CurtainUuidType;
/**
* The function that closes a curtain.
*/
export type CloseCurtainFunction = (uuid: CurtainUuidType) => void;
/**
* The function that checks if a curtain is closed (while transitioning out) or not.
*/
export type IsCurtainClosedFunction = (uuid: CurtainUuidType) => boolean;
/**
* Interface of curtains state.
*/
export interface CurtainsContextState
{
/**
* Open a new curtain.
* @param content The curtain content.
* @return UUID of the curtain.
*/
open: OpenCurtainFunction;
/**
* Close the given curtain.
* @param uuid UUID of the curtain to close.
*/
close: CloseCurtainFunction;
/**
* Check if the given curtain is closed or not.
* @param uuid UUID of the curtain to check.
*/
isClosed: IsCurtainClosedFunction;
}
const CurtainsContext = React.createContext<CurtainsContextState>({
// Empty functions.
open() { return ""; },
close() {},
isClosed() { return false; },
});
/**
* Hook to interact with curtains (open or close).
*/
export function useCurtains(): CurtainsContextState
{
return useContext(CurtainsContext);
}
/**
* A generic callable curtain element.
*/
export type CallableCurtainElement = (...args: any) => React.ReactNode;
/**
* A callable curtain function with typed parameters.
*/
export type CallableCurtain<F extends CallableCurtainElement> = (...args: Parameters<F>) => CurtainUuidType;
/**
* Callable curtain function generator.
* @param curtains The curtains context state.
* @param curtainElement The curtain element to open when called.
*/
export function callableCurtain<F extends CallableCurtainElement>(curtains: CurtainsContextState, curtainElement: React.ReactNode|F): CallableCurtain<F>
{
if (typeof curtainElement == "function")
// It's a callable curtain element, the callable curtain should be called with the same parameters.
return (...args: Parameters<F>) => curtains.open(curtainElement(...args));
else
// It's a simple element, just open it.
return () => curtains.open(curtainElement);
}
/**
* Hook to create a simple curtain.
* @param curtainElement Content of the curtain to open.
*/
export function useCallableCurtain<F extends CallableCurtainElement>(curtainElement: React.ReactNode|F): CallableCurtain<F>
{
// Get curtains context state.
const curtains = useCurtains();
// Create and keep the curtain callable in memory.
return useCallback(callableCurtain(curtains, curtainElement), [curtains, curtainElement]);
}
/**
* Page curtains provider.
*/
export function CurtainsProvider({children}: React.PropsWithChildren<{}>)
{
// Curtains state.
const [curtains, setCurtains] = useState<Record<CurtainUuidType, React.ReactNode>>({});
// Keeping track of closed curtains that are still on (while transitioning out).
const [closedCurtains, setClosedCurtains] = useState<Record<CurtainUuidType, boolean>>({});
// Initialize open curtain function.
const open = useRef<OpenCurtainFunction>();
open.current = useCallback((content: React.ReactNode) => {
// Generate a new curtain UUID for the new curtain to open.
const curtainUuid = uuidv4();
// Add the curtain to open to the list of curtains, with the generated UUID.
setCurtains({
...curtains,
[curtainUuid]: content,
});
// Return the curtain UUID.
return curtainUuid;
}, [curtains, setCurtains]);
// Initialize remove curtain function.
const remove = useRef<CloseCurtainFunction>();
remove.current = useCallback((uuid: CurtainUuidType) => {
// Copy the curtains list.
const newCurtains = {...curtains};
const newClosedCurtains = {...closedCurtains};
// Remove the given curtain from the list.
delete newCurtains[uuid];
delete newClosedCurtains[uuid];
// Set the new curtains list.
setCurtains(newCurtains);
setClosedCurtains(newClosedCurtains);
}, [curtains, setCurtains, closedCurtains, setClosedCurtains]);
// Initialize close curtain function with animation.
const close = useRef<CloseCurtainFunction>();
close.current = useCallback((uuid) => {
// Add the given curtain UUID to the list of closed curtains.
setClosedCurtains({
...closedCurtains,
[uuid]: true,
});
// Remove the curtain 1s later.
window.setTimeout(() => {
// Remove the curtain.
remove.current(uuid);
}, 1000);
}, [remove, closedCurtains, setClosedCurtains]);
// Initialize isClosed curtain function.
const isClosed = useRef<IsCurtainClosedFunction>();
isClosed.current = useCallback((uuid) => (!!closedCurtains?.[uuid]), [closedCurtains]);
// Initialize context state from action functions.
const contextState = useMemo(() => ({
open: (content: React.ReactNode) => open.current(content),
close: (uuid: CurtainUuidType) => close.current(uuid),
isClosed: (uuid: CurtainUuidType) => isClosed.current(uuid),
}), [open, close, isClosed]);
// Show dimmed main content.
useEffect(() => {
if (
Object.keys(curtains).filter((curtainId) => !closedCurtains[curtainId]).length > 0
) // We should dim content if there is at least one open curtain.
{ // Only dim if it's not already dimmed.
if (!document.body.classList.contains("dimmed")) document.body.classList.add("dimmed");
}
else
// We shouldn't dim content.
document.body.classList.remove("dimmed");
}, [curtains, closedCurtains]);
return (
<CurtainsContext.Provider value={contextState}>
{children}
<CurtainsPortal curtains={curtains} />
</CurtainsContext.Provider>
);
}
/**
* Curtains portal manager.
*/
function CurtainsPortal({curtains}: {
curtains: Record<CurtainUuidType, React.ReactNode>;
})
{
return ReactDOM.createPortal(Object.entries(curtains).map(([uuid, curtainContent]) => (
<CurtainInstance key={uuid} uuid={uuid}>
{curtainContent}
</CurtainInstance>
)), document.body);
}

View file

@ -1,108 +0,0 @@
import React, {useMemo} from "react";
import {classes} from "../../Utils";
/**
* 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={classes("calendar", 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

@ -1,65 +0,0 @@
import React, {useCallback, useMemo} from "react";
import {CaretLeft, CaretRight} from "@phosphor-icons/react";
import {Tooltip} from "../Floating/Tooltip";
import {Calendar} from "./Calendar";
import {classes} from "../../Utils";
/**
* Datepicker component.
*/
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={classes("datepicker", 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 />
</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 />
</button>
</Tooltip>
</div>
);
}

View file

@ -1,36 +0,0 @@
import React, {useCallback, useMemo} from "react";
import {ErrorBoundary, ErrorBoundaryProps} from "react-error-boundary";
import {useNotify} from "../Notifications/Notifications";
import {Notification, NotificationType} from "../Notifications/Notification";
/**
* A React error boundary that show errors in notifications.
*/
export function NotifyErrorsBoundary({children, onError, fallback, fallbackRender, FallbackComponent, ...props}: React.PropsWithChildren<Partial<ErrorBoundaryProps>>)
{
// Get notification function.
const notify = useNotify();
/**
* Error handling function.
*/
const handleError = useCallback((error: Error, info: React.ErrorInfo) => {
// Show a notification about the error.
notify(<Notification type={NotificationType.ERROR}>Unexpected error: {error.message}</Notification>);
// Then call defined onError, if there is one.
onError?.(error, info);
}, [onError]);
// Define default fallback component.
const defaultFallback = useMemo(() => <></>, []);
if (!fallback && !fallbackRender && !FallbackComponent)
// Set default fallback if nothing is set.
fallback = defaultFallback;
return (
<ErrorBoundary onError={handleError} fallback={fallback} {...props}>
{children}
</ErrorBoundary>
);
}

View file

@ -1,146 +0,0 @@
import React, {useCallback, useMemo, useState} from "react";
import {Card} from "../Card";
import {
flip,
shift,
useClick, useDismiss,
useFloating, UseFloatingOptions,
useFocus,
useHover,
useInteractions, useRole, useTransitionStyles
} from "@floating-ui/react";
import {mergeRefs} from "react-merge-refs";
import {classes} from "../../Utils";
/**
* 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";
/**
* Role of a floating element.
*/
export type FloatRole = "tooltip" | "dialog" | "alertdialog" | "menu" | "listbox" | "grid" | "tree" | "select" | "label" | "combobox";
/**
* Type of the element on which the floating element is based.
*/
export type FloatChild = (React.ReactElement & React.ClassAttributes<HTMLElement>)|Managed<(React.ReactElement & React.ClassAttributes<HTMLElement>)>;
/**
* Properties of the Float component.
*/
export interface FloatProperties
{
children: FloatChild;
content?: React.ReactNode|Managed<React.ReactNode>;
className?: string;
mode?: FloatingMode;
dismissible?: boolean;
role?: FloatRole;
floatingOptions?: UseFloatingOptions;
}
/**
* A component to show something floating next to an element.
*/
export const Float = React.forwardRef(({children, content, className, mode, dismissible, role, floatingOptions}: FloatProperties, ref): React.ReactElement => {
// By default, use "always" mode.
if (!mode) mode = "always";
if (dismissible === undefined && (mode != "always" && mode != "managed")) dismissible = true;
// 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, placement } = useFloating(
useMemo(() => (Object.assign({
open: shown,
onOpenChange: setShown,
middleware: [shift(), flip()],
} 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 dismiss = useDismiss(context, useMemo(() => ({ enabled: !!dismissible && mode != "click" }), [dismissible]));
const roleProps = useRole(context, {
role: role,
enabled: !!role,
});
const {getReferenceProps, getFloatingProps} = useInteractions([roleProps, dismiss, 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([ref, refs.setReference, child?.ref]),
},
// Get interaction properties.
getReferenceProps(),
),
);
}, [children, show, hide, ref, 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"} data-placement={placement}>
<Card style={transitionStyles} className={classes("floating", className)}>
{floatingContent}
</Card>
</div>
}
</>
);
})

View file

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

View file

@ -1,14 +0,0 @@
import React from "react";
import {Check} from "@phosphor-icons/react";
import {classes} from "../../Utils";
export function Checkbox({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement
{
return (
<label className={classes("box", className)}>
<input type={"checkbox"} {...inputProps} />
<a className={"button"} tabIndex={-1}><Check /></a>
{children}
</label>
);
}

View file

@ -1,24 +0,0 @@
import React, {useEffect, useRef} from "react";
/**
* Custom validation rule in a form.
*/
export function CustomValidationRule({valid, errorMessage}: {
valid: boolean;
errorMessage: string;
})
{
// HTML virtual input ref.
const ref = useRef<HTMLInputElement>();
// When the validation is invalid, set a custom error message, set the custom error message.
useEffect(() => {
ref.current.setCustomValidity(valid ? "" : errorMessage);
}, [ref, valid, errorMessage]);
return (
<input ref={ref}
className={"virtual"} type={"text"} required={true}
value={valid ? "true" : ""} onChange={() => {}} />
);
}

View file

@ -1,155 +0,0 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {classes, 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={classes("datepicker-input", invalidDate ? "error" : null, className)}
// Keeping focus on the input when something else in the label takes it.
onFocus={useCallback(() => inputRef.current?.focus(), [inputRef])}>
{children}
<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

@ -1,24 +0,0 @@
import React, {useState} from "react";
import {Eye, EyeSlash} from "@phosphor-icons/react";
import {classes} from "../../Utils";
export function PasswordInput({children, className, type, ...props}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement
{
const [showPassword, setShowPassword] = useState(false);
return (
<label className={classes("password", className)}>
{children}
<div>
<input type={showPassword ? "text" : "password"} {...props} />
<a className={"button"} tabIndex={-1} onClick={() => {
setShowPassword(!showPassword);
}}>
{
showPassword ? <EyeSlash /> : <Eye />
}
</a>
</div>
</label>
);
}

View file

@ -1,14 +0,0 @@
import React from "react";
import {Check} from "@phosphor-icons/react";
import {classes} from "../../Utils";
export function Radio({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement
{
return (
<label className={classes("box", className)}>
<input type={"radio"} {...inputProps} />
<a className={"button"} tabIndex={-1}><Check /></a>
{children}
</label>
);
}

View file

@ -1,7 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
export function RequiredField({className, ...props}: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement
{
return <span className={classes("required", className)} {...props}></span>;
}

View file

@ -1,117 +0,0 @@
import React, {useCallback, useEffect, useMemo, useState} from "react";
import {classes, 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={classes("timepicker", 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,14 +0,0 @@
import React from "react";
import {Check} from "@phosphor-icons/react";
import {classes} from "../../Utils";
export function ToggleSwitch({children, className, type, ...inputProps}: React.PropsWithChildren<React.InputHTMLAttributes<HTMLInputElement>>): React.ReactElement
{
return (
<label className={classes("toggleswitch", className)}>
<input type={"checkbox"} {...inputProps} />
<a className={"button"} tabIndex={-1}><span className={"switch"}></span></a>
{children}
</label>
);
}

View file

@ -1,10 +0,0 @@
import React from "react";
export function GenericLoader({children}: React.PropsWithChildren<{}>)
{
return (
<div className={"generic loader"}>
{children}
</div>
);
}

View file

@ -1,37 +0,0 @@
import React, {useCallback} from "react";
export function ListLoader({count, itemContent}: {
/**
* Sample items count.
* 3 by default.
*/
count?: number;
/**
* Sample items content or content generator function.
*/
itemContent?: React.ReactNode|((key: number) => React.ReactNode);
})
{
// Sample items count. 3 by default.
count = count === undefined ? 3 : count;
// Initialize the sample content generator.
const contentGenerator = useCallback((key: number) => (
typeof itemContent != "function"
// No function given, return the given content or a sample text.
? (itemContent ?? "Loading content...")
// A function have been given, just return its result.
: itemContent(key)
), [itemContent]);
return (
<ul className={"list loader"}>
{ // Render every sample item.
[...Array(count)].map((_, key) => (
<li key={key}><div className={"content"}>{contentGenerator(key)}</div></li>
))
}
</ul>
);
}

View file

@ -1,9 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
export function SpinningLoader({ inline }: { inline?: boolean; })
{
return (
<div className={classes("spinning loader", inline ? "inline" : undefined)}></div>
);
}

View file

@ -1,55 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
import {IconContext} from "@phosphor-icons/react";
import {NavLink, NavLinkProps} from "react-router-dom";
/**
* Main apps menu component.
*/
export function AppsMenu({className, children, ...props}: React.HTMLAttributes<HTMLDivElement>)
{
return (
<IconContext.Consumer>
{(value) => (
<IconContext.Provider value={Object.assign({}, value, {
size: 40,
weight: "regular",
})}>
<nav className={classes("apps", "menu", className)} {...props}>
<ul>
{children}
</ul>
</nav>
</IconContext.Provider>
)}
</IconContext.Consumer>
);
}
/**
* Component of an app item in apps menu.
*/
export function AppItem({className, children, ...props}: React.HTMLAttributes<HTMLAnchorElement>)
{
return (
<li>
<a className={classes("app", "flat button", className)} {...props}>
{children}
</a>
</li>
);
}
/**
* Component of an app link in apps menu.
*/
export function AppLink({className, children, ...props}: NavLinkProps & React.HTMLAttributes<HTMLAnchorElement>)
{
return (
<li>
<NavLink className={classes("app", "flat button", className)} {...props}>
{children}
</NavLink>
</li>
);
}

View file

@ -1,16 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
/**
* Main component of a main menu.
*/
export function MainMenu({children, className, ...props}: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>)
{
return (
<nav className={classes("main", "menu", className)} {...props}>
<ul>
{children}
</ul>
</nav>
);
}

View file

@ -1,57 +0,0 @@
import React from "react";
import {classes, Modify} from "../../Utils";
import {SubmenuFloat} from "./SubmenuFloat";
import {NavLink, NavLinkProps} from "react-router-dom";
/**
* Main menu item properties.
*/
export type MainMenuItemProperties = React.PropsWithChildren<Modify<React.AnchorHTMLAttributes<HTMLAnchorElement>, {
}>>;
/**
* Main component of a main menu item.
*/
export const MainMenuItem = React.forwardRef<HTMLAnchorElement, MainMenuItemProperties>(function SubmenuItem({children, ...props}: MainMenuItemProperties, ref)
{
return (
<li>
<a ref={ref} {...props}>{children}</a>
</li>
);
});
/**
* A main menu item link properties.
*/
export type MainMenuLinkProperties = React.PropsWithChildren<Modify<NavLinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>, {
}>>;
/**
* A main menu item link.
*/
export const MainMenuLink = React.forwardRef<HTMLAnchorElement, MainMenuLinkProperties>(function MainMenuLink({children, ...props}: MainMenuLinkProperties, ref)
{
return (
<li>
<NavLink ref={ref} {...props}>{children}</NavLink>
</li>
);
});
/**
* A main menu item that open a submenu.
*/
export function MainMenuItemSubmenu({submenu, className, children, ...props}: Modify<MainMenuItemProperties, {
/**
* The submenu content.
*/
submenu: React.ReactNode;
}>)
{
return (
<SubmenuFloat submenu={submenu} role={"menu"} floatingOptions={{ placement: "bottom-start" }}>
<MainMenuItem className={classes("submenu", className)} {...props}>{children}</MainMenuItem>
</SubmenuFloat>
);
}

View file

@ -1,14 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
/**
* Main component of a submenu.
*/
export function Submenu({className, children, ...props}: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>)
{
return (
<div className={classes("submenu", className)} {...props}>
{children}
</div>
);
}

View file

@ -1,25 +0,0 @@
import React from "react";
import {Float, FloatProperties} from "../Floating/Float";
import {classes, Modify} from "../../Utils";
/**
* Add a submenu which opens on click on the child.
*/
export function SubmenuFloat({submenu, className, mode, dismissible, children, content, ...props}: Modify<FloatProperties, {
/**
* The submenu content.
*/
submenu: React.ReactNode;
// Ignored overridden properties.
content?: never;
}>)
{
return (
<Float mode={mode ?? "click"} dismissible={dismissible ?? true}
className={classes("submenu", className)}
content={submenu} {...props}>
{children}
</Float>
);
}

View file

@ -1,53 +0,0 @@
import React from "react";
import {classes, Modify} from "../../Utils";
import {SubmenuFloat} from "./SubmenuFloat";
import {NavLink, NavLinkProps} from "react-router-dom";
/**
* Submenu item properties.
*/
export type SubmenuItemProperties = React.PropsWithChildren<Modify<React.AnchorHTMLAttributes<HTMLAnchorElement>, {
}>>;
/**
* Main component of a submenu item.
*/
export const SubmenuItem = React.forwardRef<HTMLAnchorElement, SubmenuItemProperties>(function SubmenuItem({className, children, ...props}: SubmenuItemProperties, ref)
{
return (
<a ref={ref} className={classes("item", className)} {...props}>{children}</a>
);
});
/**
* A submenu item link properties.
*/
export type SubmenuLinkProperties = React.PropsWithChildren<Modify<NavLinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>, {
}>>;
/**
* A submenu item link.
*/
export const SubmenuLink = React.forwardRef<HTMLAnchorElement, SubmenuLinkProperties>(function SubmenuLink({className, children, ...props}: SubmenuLinkProperties, ref)
{
return (
<NavLink ref={ref} className={classes("item", className)} {...props}>{children}</NavLink>
);
});
/**
* A submenu item that open a submenu.
*/
export function SubmenuItemSubmenu({submenu, className, children, ...props}: Modify<SubmenuItemProperties, {
/**
* The submenu content.
*/
submenu: React.ReactNode;
}>)
{
return (
<SubmenuFloat submenu={submenu} role={"menu"} floatingOptions={{ placement: "right-start" }}>
<SubmenuItem className={classes("submenu", className)} {...props}>{children}</SubmenuItem>
</SubmenuFloat>
);
}

View file

@ -1,68 +0,0 @@
import React from "react";
import {X} from "@phosphor-icons/react";
import {useCurtains, useCallableCurtain} from "../Curtains/Curtains";
import {classes, Modify} from "../../Utils";
import {ModalType, ModalTypeIcon} from "./ModalsTypes";
import {useCurtain} from "../Curtains/CurtainInstance";
/**
* More natural name of useCurtains for modals.
* @see useCurtains
*/
export const useModals = useCurtains;
/**
* More natural name of useCurtain for modals.
* @see useCurtain
*/
export const useModal = useCurtain;
/**
* More natural name of useCallableCurtain for modals.
* @see useCallableCurtain
*/
export const useCallableModal = useCallableCurtain;
/**
* Modal main component.
*/
export function Modal({className, title, closable, type, children, ...props}: React.PropsWithChildren<Modify<React.HTMLAttributes<HTMLDivElement>, {
/**
* Modal title.
*/
title?: string;
/**
* Can disable close button.
*/
closable?: boolean;
/**
* Modal type. None by default.
*/
type?: ModalType;
}>>)
{
// Modal is closable by default.
closable = closable !== undefined ? closable : true;
// Modal type is NONE by default.
type = type !== undefined ? type : ModalType.NONE;
// Modal state.
const {close} = useModal();
return (
<div className={classes("modal", type, className)} {...props}>
<header>
<h1>{ModalTypeIcon[type]} {title ?? ""}</h1>
<button className={"icon-only close"} onClick={close} disabled={!closable}><X size={20} /></button>
</header>
<main>
{children}
</main>
</div>
);
}

View file

@ -1,25 +0,0 @@
import React from "react";
import {CheckCircle, Info, Record, Warning, WarningDiamond} from "@phosphor-icons/react";
/**
* Modal types enumeration.
*/
export enum ModalType
{
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
NONE = "none",
}
/**
* Icon for each modal type.
*/
export const ModalTypeIcon: Record<ModalType, React.ReactElement> = {
[ModalType.INFO]: <Info size={24} weight={"duotone"} />,
[ModalType.SUCCESS]: <CheckCircle size={24} weight={"duotone"} />,
[ModalType.WARNING]: <Warning size={24} weight={"duotone"} />,
[ModalType.ERROR]: <WarningDiamond size={24} weight={"duotone"} />,
[ModalType.NONE]: null,
};

View file

@ -1,41 +0,0 @@
import React, {useCallback, useContext} from "react";
import {classes} from "../../Utils";
import {NotificationContext, NotificationsContext} from "./Notifications";
/**
* Notifications types enumeration.
*/
export enum NotificationType
{
NONE = "none",
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
}
/**
* Notification component.
*/
export function Notification({type, children}: React.PropsWithChildren<{
type?: NotificationType;
}>)
{
// Default type is NONE.
type = type ?? NotificationType.NONE;
// Get notifications context.
const {close} = useContext(NotificationsContext);
// Get current notification UUID.
const {uuid, closed} = useContext(NotificationContext);
// Initialize close notification function.
const closeNotification = useCallback(() => { close(uuid); }, [uuid]);
return (
<li className={classes("notification", type, closed ? "closed" : undefined)} onMouseDown={closeNotification}>
{children}
</li>
);
}

View file

@ -1,217 +0,0 @@
import React, {startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react";
import ReactDOM from "react-dom";
import {v4 as uuidv4} from "uuid";
/**
* Notification UUID type.
*/
export type NotificationUuid = string;
/**
* Notification data.
*/
export interface NotificationData
{
/**
* The notification content.
*/
content: React.ReactNode;
}
/**
* Type of notification emitter function.
*/
export type EmitNotificationFunction = (content: React.ReactNode) => void;
/**
* Type of notification close function.
*/
export type RemoveNotificationFunction = (uuid: NotificationUuid) => void;
/**
* Type of notification close function.
*/
export type CloseNotificationFunction = (uuid: NotificationUuid) => void;
/**
* Type of notification closed state function.
*/
export type IsNotificationClosedFunction = (uuid: NotificationUuid) => boolean;
/**
* Interface of notifications state.
*/
export interface NotificationsContextState
{
/**
* Notification function.
* @param content Notification content.
*/
notify: EmitNotificationFunction;
/**
* Close notification function.
* @param uuid UUID of notification to close.
*/
close: CloseNotificationFunction;
/**
* Is given notification closed?
* @param uuid UUID of notification to get closed state.
*/
isClosed: IsNotificationClosedFunction;
}
export const NotificationsContext = React.createContext<NotificationsContextState>({
notify() {},
close() {},
isClosed() { return false; },
});
/**
* Hook to emit a notification.
*/
export function useNotify(): EmitNotificationFunction
{
return useContext(NotificationsContext).notify;
}
/**
* Notifications provider.
*/
export function NotificationsProvider({children}: React.PropsWithChildren<{}>)
{
// Notifications.
const [notifications, setNotifications] = useState<Record<NotificationUuid, NotificationData>>({});
// Keeping track of closed notifications that are still on (while transitioning out).
const [closedNotifications, setClosedNotifications] = useState<Record<NotificationUuid, boolean>>()
// Initialize remove notification function.
const remove = useRef<RemoveNotificationFunction>();
remove.current = useCallback((uuid) => {
// Copy the notifications list.
const newNotifications = {...notifications};
const newClosedNotifications = {...closedNotifications};
// Remove the given notification from the list.
delete newNotifications[uuid];
delete newClosedNotifications[uuid];
// Set the new notifications list.
setNotifications(newNotifications);
setClosedNotifications(newClosedNotifications);
}, [notifications, setNotifications, closedNotifications, setClosedNotifications]);
// Initialize close notification function with animation.
const close = useRef<CloseNotificationFunction>();
close.current = useCallback((uuid) => {
// Add the given curtain UUID to the list of closed curtains.
setClosedNotifications({
...closedNotifications,
[uuid]: true,
});
// Remove the notification 300ms later.
window.setTimeout(() => {
// Remove the curtain.
remove.current(uuid);
}, 300);
}, [remove, closedNotifications, setClosedNotifications]);
// Initialize isClosed notification function.
const isClosed = useRef<IsNotificationClosedFunction>();
isClosed.current = useCallback((uuid) => (!!closedNotifications?.[uuid]), [closedNotifications]);
// Initialize notify function.
const notify = useRef<EmitNotificationFunction>();
notify.current = useCallback((content: React.ReactNode) => {
// Generate a new notification UUID for the new notification.
const notificationUuid = uuidv4();
// Add the notification to the list of notifications, with the generated UUID.
setNotifications({
...notifications,
[notificationUuid]: {
content: content,
},
});
// Close notification 10s later.
setTimeout(() => {
close.current(notificationUuid);
}, 10000);
}, [notifications, setNotifications]);
// Initialize context state from action functions.
const contextState = useMemo(() => ({
notify: (content: React.ReactNode) => notify.current(content),
close: (uuid: NotificationUuid) => close.current(uuid),
isClosed: (uuid: NotificationUuid) => isClosed.current(uuid),
}), [notify, isClosed]);
return (
<NotificationsContext.Provider value={contextState}>
{children}
<NotificationsPortal notifications={notifications} />
</NotificationsContext.Provider>
);
}
/**
* Curtains portal manager.
*/
function NotificationsPortal({notifications}: {
notifications: Record<NotificationUuid, NotificationData>;
})
{
return ReactDOM.createPortal((
<ul className={"notifications"}>
{ // Show notifications list.
Object.entries(notifications).map(([uuid, notificationData]) => (
<NotificationInstance key={uuid} uuid={uuid}>
{notificationData.content}
</NotificationInstance>
))
}
</ul>
), document.body);
}
/**
* A notification context.
*/
export const NotificationContext = React.createContext<{
/**
* Notification UUID.
*/
uuid: NotificationUuid;
/**
* Notification closed state.
*/
closed: boolean;
}>(undefined);
/**
* Notification component.
*/
function NotificationInstance({uuid, children}: React.PropsWithChildren<{
/**
* Notification UUID.
*/
uuid: NotificationUuid;
}>)
{
// Get notifications context.
const {isClosed} = useContext(NotificationsContext);
return (
<NotificationContext.Provider value={{
uuid: uuid,
closed: isClosed(uuid),
}}>
{children}
</NotificationContext.Provider>
);
}

View file

@ -1,137 +0,0 @@
import React, {useCallback, useState} from "react";
import {Pagination} from "./Pagination";
import {SpinningLoader} from "../Loaders/SpinningLoader";
import {Await, useAsync} from "../../Async";
/**
* Paginated content component with custom page handling.
*/
export function Paginate({ page, onChange, count, children }: React.PropsWithChildren<{
/**
* The current page.
*/
page: number;
/**
* Called when a new page is selected.
* @param newPage The newly selected page.
*/
onChange: (newPage: number) => void;
/**
* Pages count.
*/
count: number;
}>)
{
return (
<>
{children}
<Pagination page={page} onChange={onChange} count={count} />
</>
);
}
/**
* Paginated content component.
*/
export function AutoPaginate({ count, children }: {
/**
* Pages count.
*/
count: number;
/**
* Show the given page.
* @param page The page to show.
*/
children: (page: number) => React.ReactElement;
})
{
// The current page.
const [page, setPage] = useState<number>(1);
return (
<Paginate page={page} onChange={setPage} count={count}>
{children(page)}
</Paginate>
);
}
/**
* Asynchronous paginated content component.
*/
export function AsyncPaginate<T>({ count, getData, children }: {
/**
* Get pages count.
*/
count: () => Promise<number>;
/**
* Get data for the given page.
* @param page The page for which to get data.
*/
getData: (page: number) => Promise<T>;
/**
* Show the current page with its retrieved data.
* @param data Data of the page to show.
* @param page The page to show.
*/
children: (data: T, page: number) => React.ReactElement;
})
{
// Getting pages count.
const [asyncCount] = useAsync(count, []);
return (
<Await async={asyncCount} fallback={<SpinningLoader />}>
{
(count) => (
<AutoPaginate count={count}>
{(page) => <AsyncPage page={page} getData={getData} render={children} />}
</AutoPaginate>
)
}
</Await>
);
}
/**
* An async page to render.
*/
export function AsyncPage<T>({page, getData, render}: {
/**
* The page number to show.
*/
page: number;
/**
* Get data for the given page.
* @param page The page for which to get data.
*/
getData: (page: number) => Promise<T>;
/**
* Render the page with its retrieved data.
* @param data Data of the page to show.
* @param page The page to show.
*/
render: (data: T, page: number) => React.ReactElement;
})
{
// Store function to get page data.
const getPageData = useCallback(() => {
return getData(page);
}, [page]);
// Getting page data.
const [asyncPageData] = useAsync(getPageData, [getPageData]);
return (
<Await async={asyncPageData} fallback={<SpinningLoader />}>
{(pageData) => render(pageData, page)}
</Await>
);
}

View file

@ -1,141 +0,0 @@
import React, {useEffect, useState} from "react";
import {CaretLeft, CaretRight} from "@phosphor-icons/react";
import {Tooltip} from "../Floating/Tooltip";
import {usePreviousValue} from "../../Utils";
/**
* Pagination component.
*/
export function Pagination({ page, onChange, count }: {
/**
* The current page.
*/
page: number;
/**
* Called when a new page is selected.
* @param newPage The newly selected page.
*/
onChange: (newPage: number) => void;
/**
* Pages count.
*/
count: number;
})
{
// Memorize previous page.
const previousPage = usePreviousValue(page);
// The input text to use, when the currently entered value is not a number.
const [inputText, setInputText] = useState(undefined);
useEffect(() => {
if (page != previousPage)
// If the page has changed, resetting the input text.
setInputText(undefined);
}, [previousPage, page]);
return (
<nav className={"pages"}>
<Tooltip content={"Previous"}>
<button className={"flat icon-only"} disabled={page <= 1} onClick={() => onChange(page - 1)}>
<CaretLeft/>
</button>
</Tooltip>
<ul>
{ // First page, only show when the current page isn't the first page.
page > 1 &&
<li>
<button className={"flat"} onClick={() => onChange(1)}>1</button>
</li>
}
{ // Space between first page and shown pages.
// Only show when the page isn't the first page.
page - 4 > 1 &&
<li>
<button className={"flat"} onClick={page - 4 > 2 ? undefined : () => onChange(page - 4)}>{page - 4 > 2 ? "···" : page - 4}</button>
</li>
}
{ // Only show when the page isn't the first page.
page - 3 > 1 &&
<li>
<button className={"flat"} onClick={() => onChange(page - 3)}>{page - 3}</button>
</li>
}
{ // Only show when the page isn't the first page.
page - 2 > 1 &&
<li>
<button className={"flat"} onClick={() => onChange(page - 2)}>{page - 2}</button>
</li>
}
{ // Only show when the page isn't the first page.
page - 1 > 1 &&
<li>
<button className={"flat"} onClick={() => onChange(page - 1)}>{page - 1}</button>
</li>
}
{ // Current page input.
<li className={"current"}>
<input type={"number"} step={1} min={1} max={count} value={inputText ?? page} onChange={(event) => {
// Try to get the new value, between 1 and page count.
const newValue = Math.max(Math.min(event.currentTarget.valueAsNumber, count), 1);
if (isNaN(newValue))
// Not a numeric value, keep the current input text without changing the page number.
setInputText(event.currentTarget.value);
else
{ // The value has been read successfully, changing it and resetting input text.
onChange(newValue);
setInputText(undefined);
}
}} />
</li>
}
{ // Only show when the page isn't the last page.
page + 1 < count &&
<li>
<button className={"flat"} onClick={() => onChange(page + 1)}>{page + 1}</button>
</li>
}
{ // Only show when the page isn't the last page.
page + 2 < count &&
<li>
<button className={"flat"} onClick={() => onChange(page + 2)}>{page + 2}</button>
</li>
}
{ // Only show when the page isn't the last page.
page + 3 < count &&
<li>
<button className={"flat"} onClick={() => onChange(page + 3)}>{page + 3}</button>
</li>
}
{ // Space between shown pages and last page.
// Only show when the page isn't the last page.
page + 4 < count &&
<li>
<button className={"flat"} onClick={page + 4 < (count - 1) ? undefined : () => onChange(page + 4)}>{page + 4 < (count - 1) ? "···" : page + 4}</button>
</li>
}
{ // Last page, only show when the current page isn't the last page.
page < count &&
<li>
<button className={"flat"} onClick={() => onChange(count)}>{count}</button>
</li>
}
</ul>
<Tooltip content={"Next"}>
<button className={"flat icon-only"} disabled={page >= count} onClick={() => onChange(page + 1)}>
<CaretRight/>
</button>
</Tooltip>
</nav>
);
}

View file

@ -1,132 +0,0 @@
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 /></span>
</a>
));
}

View file

@ -1,250 +0,0 @@
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";
import {CustomValidationRule} from "../Forms/CustomValidationRule";
/**
* Generic select component properties.
*/
export type SelectProperties<OptionKey extends keyof any, Option> = 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;
/**
* Min count of options to allow to select.
* 0 by default, 1 when required is true.
*/
selectibleMinCount?: number;
/**
* 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;
}>>;
/**
* Generic select component.
*/
export function Select<OptionKey extends keyof any, Option>(
{
className,
value, onChange,
options, renderOption, match,
required, selectibleMinCount,
selectibleMaxCount, multiple,
blurOnSelect, blurWhenMaxCountSelected,
// Properties to pass down.
onKeyDown,
// Already set properties.
type, role,
children,
...props
}: SelectProperties<OptionKey, Option>): 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);
// By default, allow to select no option.
// If `required` is set, `selectibleMinCount` will be set as 1 by default.
selectibleMinCount = selectibleMinCount ?? (required ? 1 : 0);
// 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 /></a>
</div>
<CustomValidationRule valid={selectedOptions.length >= selectibleMinCount}
errorMessage={`At least ${selectibleMinCount} option${selectibleMinCount > 1 ? "s are" : " is"} required.`} />
<ul className={"selected"}>
{ // Showing each selected value.
selectedOptions.map(([optionKey, option]) => (
<li key={String(optionKey)}>
<Check />
<div className={"option"}>{(renderOption ?? defaultRenderOption)(option)}</div>
<button className={"remove flat"} type={"button"} onMouseDown={() => handleDeselectedOption(optionKey)}>
<X />
</button>
</li>
))
}
</ul>
<div className={"label"}>
{children}
</div>
</label>
);
}

View file

@ -1,11 +0,0 @@
import React from "react";
export function SimpleSuggestions(): React.ReactElement
{
return (
<>
<a className={"suggestion"}>test</a>
<a className={"suggestion"}>another</a>
</>
);
}

View file

@ -1,23 +0,0 @@
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"} dismissible={false} content={suggestions} mode={mode} {...props}>
{children}
</Float>
);
}

View file

@ -1,109 +0,0 @@
import React, {useEffect} from "react";
import {
GlobalStateProvider,
useGlobalStateReducers, useGlobalStateValue,
} from "../../GlobalState";
import {usePreviousValue} from "../../Utils";
import {StepKeyType, stepsGlobalState, useCurrentStepKey, useStepsNavigator} from "./StepsContext";
/**
* Main Steps component.
*/
export function Steps({children}: React.PropsWithChildren<{}>)
{
return (
<GlobalStateProvider globalState={stepsGlobalState}>
<div className={"steps"}>
<StepsNavigatorComponent />
<div>
{children}
</div>
</div>
</GlobalStateProvider>
);
}
/**
* Steps navigator component.
*/
export function StepsNavigatorComponent()
{
// Get the steps navigator functions.
const stepsNavigator = useStepsNavigator();
// Get the current steps state.
const stepsState = useGlobalStateValue(stepsGlobalState);
// Get the current step.
const currentStep = useCurrentStepKey();
return (
<nav className={"steps"}>
<ul>
{ // Showing a button for each step.
stepsState.steps.map((step, index) => (
// Rendering the current step button.
<li key={step.key} className={currentStep == step.key ? "active" : undefined}>
<button type={"button"} onClick={() => {
stepsNavigator.set(step.key);
}}>
{step.title ?? (index + 1)}
</button>
</li>
))
}
</ul>
</nav>
);
}
/**
* Component of a step.
*/
export function Step({stepKey, stepTitle, children}: React.PropsWithChildren<{
/**
* The current step unique key.
*/
stepKey: StepKeyType;
/**
* The step title, to show in the navigator.
*/
stepTitle?: React.ReactNode;
}>)
{
// Get the global state reducers class.
const stepsGlobalStateReducers = useGlobalStateReducers(stepsGlobalState);
// Get the previous step key.
const previousStepKey = usePreviousValue(stepKey);
useEffect(() => {
// Remove the previous step key, if there is one.
if (previousStepKey) stepsGlobalStateReducers.removeStep(previousStepKey);
// Register the current step key.
stepsGlobalStateReducers.registerStep(stepKey, stepTitle);
// Remove the step key when component is removed.
return () => stepsGlobalStateReducers.removeStep(stepKey);
}, [stepsGlobalStateReducers, previousStepKey, stepTitle]);
// Get the current step key.
const currentStep = useCurrentStepKey();
if (currentStep != stepKey)
// If this step is not the current one, rendering nothing.
return undefined;
// Rendering the current step.
return (
<section className={"step"}>
{children}
</section>
);
}

View file

@ -1,165 +0,0 @@
import React, {useMemo} from "react";
import {GlobalState, GlobalStateReducers, useGlobalStateReducers, useGlobalStateValue} from "../../GlobalState";
/**
* Type of step key.
*/
export type StepKeyType = string;
/**
* Steps global state data type.
*/
export interface StepsGlobalStateType
{
/**
* Steps list, by their order of apparition.
*/
steps: { key: StepKeyType; title?: React.ReactNode; }[];
/**
* The index of the current step in the steps list.
*/
currentStepIndex: number;
}
/**
* The steps global state modification functions.
*/
class StepsGlobalStateReducers extends GlobalStateReducers<StepsGlobalStateType>
{
/**
* Register a new step with its step key.
* @param newStepKey The step key to register.
*/
registerStep(newStepKey: StepKeyType, newStepTitle?: React.ReactNode): void
{
this.setState({
// Add the given step key to the steps array, ensuring that it will be there only once.
steps: [...this.state.steps.filter(({key: currentStepKey}) => currentStepKey != newStepKey), { key: newStepKey, title: newStepTitle }]
});
}
/**
* Remove an old step with its step key.
* @param oldStepKey The step key to remove.
*/
removeStep(oldStepKey: StepKeyType): void
{
this.setState({
// Remove the given step key from the steps array.
steps: this.state.steps.filter(({key: currentStepKey}) => currentStepKey != oldStepKey)
});
}
/**
* Set the current step from its key.
* @param stepKey The step key to set as the current one.
*/
setCurrentStep(stepKey: StepKeyType): void
{
// Get the new step index.
const stepIndex = this.state.steps.findIndex(({key: currentStep}) => currentStep == stepKey);
if (stepIndex >= 0)
{ // If the new step index has been found, setting it as the current one.
this.setState({
currentStepIndex: stepIndex,
});
}
}
/**
* Get the next step key.
*/
getNextStep(): StepKeyType
{
return this.state.steps[this.state.currentStepIndex + 1]?.key ?? this.state.steps[0]?.key;
}
/**
* Get the previous step key.
*/
getPreviousStep(): StepKeyType
{
return this.state.steps[this.state.currentStepIndex - 1]?.key ?? this.state.steps[this.state.steps.length - 1]?.key;
}
}
/**
* The steps global state.
*/
export const stepsGlobalState = new GlobalState<StepsGlobalStateType, StepsGlobalStateReducers>({
steps: [],
currentStepIndex: 0,
}, new StepsGlobalStateReducers);
/**
* Hook of the current step key in a steps component.
*/
export function useCurrentStepKey(): StepKeyType
{
// Get the current steps global state value.
const stepsState = useGlobalStateValue(stepsGlobalState);
// Get the current step from the global state value.
return stepsState.steps.length > 0
? ( // If there are steps, getting the current one.
stepsState.currentStepIndex < stepsState.steps.length
? ( // If the index is correctly defined, trying to get the corresponding state key
stepsState.currentStepIndex >= 0 ? (stepsState.steps[stepsState.currentStepIndex]?.key) : stepsState.steps[0]?.key
)
// The current index is too high, taking the last step as the current one.
: stepsState.steps[stepsState.steps.length - 1]?.key
)
// There are no steps, returning none.
: undefined;
}
/**
* Steps navigator
*/
export interface StepsNavigator
{
/**
* Go to the next step.
*/
next(): void;
/**
* Return to the previous step.
*/
previous(): void;
/**
* Go to the given step.
* @param stepKey The step key to join.
*/
set(stepKey: StepKeyType): void;
}
/**
* Hook of the steps' navigator.
*/
export function useStepsNavigator(): StepsNavigator
{
// Get the steps reducers instance.
const stepsReducers = useGlobalStateReducers(stepsGlobalState);
// Create the steps navigator object.
return useMemo(() => ({
next()
{
stepsReducers.setCurrentStep(stepsReducers.getNextStep());
},
previous()
{
stepsReducers.setCurrentStep(stepsReducers.getPreviousStep());
},
set(stepKey: StepKeyType)
{
stepsReducers.setCurrentStep(stepKey);
},
}), [stepsReducers]);
}

View file

@ -1,59 +0,0 @@
import React from "react";
import {X} from "@phosphor-icons/react";
import {useCallableCurtain, useCurtains} from "../Curtains/Curtains";
import {classes, Modify} from "../../Utils";
import {useCurtain} from "../Curtains/CurtainInstance";
/**
* More natural name of useCurtains for subapps.
* @see useCurtains
*/
export const useSubapps = useCurtains;
/**
* More natural name of useCurtain for subapps.
* @see useCurtain
*/
export const useSubapp = useCurtain;
/**
* More natural name of useCallableCurtain for subapps.
* @see useCallableCurtain
*/
export const useCallableSubapp = useCallableCurtain;
/**
* Subapp main component.
*/
export function Subapp({className, title, closable, children}: React.PropsWithChildren<Modify<React.HTMLAttributes<HTMLDivElement>, {
/**
* Subapp title.
*/
title?: string;
/**
* Can disable close button.
*/
closable?: boolean;
}>>)
{
// Subapp is closable by default.
closable = closable !== undefined ? closable : true;
// Subapp state.
const {close} = useSubapp();
return (
<div className={classes("subapp", className)}>
<header>
<h1>{title ?? ""}</h1>
<button className={"icon-only close"} onClick={close} disabled={!closable}><X size={32}/></button>
</header>
<main>
{children}
</main>
</div>
);
}

View file

@ -1,17 +0,0 @@
import React from "react";
import {classes} from "../../Utils";
import {Info} from "@phosphor-icons/react";
/**
* Simple text tip component.
*/
export function Tip({className, children, ...props}: React.PropsWithChildren<React.HTMLAttributes<HTMLParagraphElement>>)
{
return (
<p className={classes("tip", className)} {...props}>
<Info weight={"duotone"} />
{children}
</p>
);
}

View file

@ -1,184 +0,0 @@
import React, {PropsWithChildren, useCallback, useContext, useState} from "react";
/**
* Global state modifiers functions.
*/
export class GlobalStateReducers<S>
{
/**
* The current state (readonly).
* @protected
*/
protected state: Readonly<S>;
/**
* State update system.
* @private
*/
private stateUpdater: React.Dispatch<S>;
/**
* Assign changes to the current state.
* @param stateUpdate Changes to apply to the current state.
*/
setState(stateUpdate: Partial<S>)
{
// Apply update object to the current state.
this.state = Object.assign({}, this.state, stateUpdate);
// Update the changed state.
this.stateUpdater(this.state);
}
}
/**
* Reducers definition interface for easy type definition.
*/
interface ReducerHack<S>
{
/**
* The current state (readonly).
*/
state: Readonly<S>;
/**
* State update system.
*/
stateUpdater: React.Dispatch<S>;
}
/**
* Main global state class.
*/
export class GlobalState<S, R extends GlobalStateReducers<S>>
{
/**
* Global state context.
* @protected
*/
protected context: React.Context<S>;
/**
* State update system, used in the reducers class.
* @protected
*/
protected stateUpdater: React.Dispatch<S>;
/**
* Create a new global state.
* @param defaultValue The default value of the global state.
* @param reducers Reducers class.
*/
constructor(protected defaultValue: S, protected reducers: R)
{
// Create a new context for the global state.
this.context = React.createContext<S>(defaultValue);
}
/**
* Get the context for the global state.
*/
getContext(): React.Context<S>
{ return this.context; }
/**
* Get the default value of the global state.
*/
getDefaultValue(): S
{ return this.defaultValue; }
/**
* Assign the state update system, used in the reducers class.
* @param updater State update system instance.
*/
setStateUpdater(updater: React.Dispatch<S>): void
{
this.stateUpdater = updater;
}
/**
* Get the configured state reducers class for the current state.
* @param state The state on which the reducers should apply.
*/
getStateReducers(state: S): R
{
// Assign the given state.
(this.reducers as unknown as ReducerHack<S>).state = state;
// Assign the current state updater.
(this.reducers as unknown as ReducerHack<S>).stateUpdater = this.stateUpdater;
return this.reducers;
}
}
/**
* Global state Provider hook.
* @param globalState Global state for which to get the global state Provider.
*/
export function useGlobalStateProvider<S, R extends GlobalStateReducers<S>>(globalState: GlobalState<S, R>): React.FunctionComponent<PropsWithChildren<{}>>
{
// Get the provider from the given global state context.
const Provider = globalState.getContext().Provider;
// Create the global state provider component.
return useCallback((props: PropsWithChildren<{}>) => {
// Get the current state of the global state.
const [state, setState] = useState(globalState.getDefaultValue());
// Pass the state update system to the global state.
globalState.setStateUpdater(setState);
// Use the context provider and render children.
return (
<Provider value={state}>
{props.children}
</Provider>
);
}, [Provider]);
}
/**
* Global state Provider component
* @constructor
*/
export function GlobalStateProvider<S, R extends GlobalStateReducers<S>>({globalState, children}: React.PropsWithChildren<{
/**
* Global state for which to get the global state Provider.
*/
globalState: GlobalState<S, R>;
}>)
{
// Get the current global state provider.
const Provider = useGlobalStateProvider(globalState);
return (
// Render the children inside the provider.
<Provider>{children}</Provider>
);
}
/**
* Get the global state and its reducers.
* @param globalState The global state to get.
*/
export function useGlobalState<S, R extends GlobalStateReducers<S>>(globalState: GlobalState<S, R>): [S, R]
{
// Get the global state data (from its context state).
const ctx = useContext(globalState.getContext());
// Return the context state (global state data), and the global state reducers for this current state.
return [ctx, globalState.getStateReducers(ctx)];
}
/**
* Get the reducers of the global state.
* @param globalState The global state for which to get the reducers.
*/
export function useGlobalStateReducers<S, R extends GlobalStateReducers<S>>(globalState: GlobalState<S, R>): R
{
// Return the global state reducers for the current global state.
return globalState.getStateReducers(useContext(globalState.getContext()));
}
/**
* Get the global state data.
* @param globalState The global state for which to get the data.
*/
export function useGlobalStateValue<S, R extends GlobalStateReducers<S>>(globalState: GlobalState<S, R>): S
{
// Return the global state data (from its context state).
return useContext(globalState.getContext());
}

View file

@ -1,133 +0,0 @@
import React, {useCallback, useContext, useMemo, useState} from "react";
import {v4 as uuidv4} from "uuid";
/**
* Create Kernel context.
* @param defaultValue Kernel context default value.
*/
export function createKernelContext<T>(defaultValue: T): KernelContext<T>
{
return {
uuid: uuidv4(),
defaultValue: defaultValue,
};
}
/**
* Kernel context definition object.
*/
export interface KernelContext<T>
{
/**
* Kernel context UUID.
*/
uuid: string;
/**
* Kernel context default value.
*/
defaultValue: T;
}
/**
* Kernel context setter function type.
*/
export type KernelContextSetter<IdentifierType extends string|symbol|number = string, ValueType = any> = (identifier: IdentifierType, value: ValueType) => void;
/**
* Kernel contexts type.
*/
export type KernelContexts = Record<string, any>;
/**
* Kernel context dispatcher function type.
*/
export type KernelContextDispatcher<ValueType = any> = (value: ValueType) => void;
/**
* Kernel global context data.
*/
export interface KernelGlobalContextData
{
contexts: KernelContexts;
setContext: KernelContextSetter;
}
/**
* React Kernel global context.
*/
export const KernelGlobalContext = React.createContext<KernelGlobalContextData>({
contexts: {},
setContext: () => {},
});
/**
* Kernel global context provider.
*/
export function KernelGlobalContextProvider({children}: React.PropsWithChildren<{}>)
{
// Kernel contexts initialization.
const [kernelContexts, setKernelContexts] = useState<KernelContexts>({});
/**
* Change kernel contexts.
*/
const changeKernelContexts = useCallback((kernelContextsUpdate: Partial<KernelContexts>) => {
setKernelContexts({
...kernelContexts,
...kernelContextsUpdate,
});
}, [kernelContexts, setKernelContexts]);
/**
* Kernel context setter function.
*/
const kernelContextSetter = useCallback((identifier: string, data: any) => (
changeKernelContexts({
[identifier]: data,
})
), [changeKernelContexts]);
// Initialize kernel global context value.
const kernelContextValue = useMemo<KernelGlobalContextData>(() => ({
contexts: kernelContexts,
setContext: kernelContextSetter,
}), [kernelContexts, kernelContextSetter]);
return (
<KernelGlobalContext.Provider value={kernelContextValue}>
{children}
</KernelGlobalContext.Provider>
);
}
/**
* Get kernel global context data.
*/
function useKernelGlobalContext(): KernelGlobalContextData
{
return useContext(KernelGlobalContext);
}
/**
* Initialize or get kernel context with given identifier.
* @param context Context object.
*/
export function useKernelContext<T>(context: KernelContext<T>): [T, KernelContextDispatcher<T>]
{
/**
* Get kernel global context.
*/
const kernelGlobalContext = useKernelGlobalContext();
/**
* Function to set kernel context.
*/
const setContext = useCallback(
(newValue: T) => kernelGlobalContext.setContext(context.uuid, newValue),
[kernelGlobalContext.setContext],
);
// Return kernel context and its dispatcher.
return [kernelGlobalContext.contexts?.[context.uuid] ?? context.defaultValue, setContext];
}

View file

@ -1,68 +0,0 @@
import React, {DependencyList, FormEvent, useCallback, useEffect, useRef} from "react";
export type Modify<T, R> = Omit<T, keyof R> & R;
/**
* Merge multiple class names to one full class name.
* @param className Class names.
*/
export function classes(...className: (string|null|undefined|false)[]): string
{
return className.filter((className) => !!className).join(" ");
}
export function formatDate(date: Date): string
{
return ((date.getDate() < 10 ? "0" : "") + date.getDate()) + "/" + (((date.getMonth() + 1) < 10 ? "0" : "") + (date.getMonth() + 1)) + "/" + date.getFullYear();
}
export function formatTime(date: Date): string
{
return (date.getHours() < 10 ? "0" : "") + date.getHours() + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes();
}
/**
* Normalize a given string for searching.
* @param str The string to normalize.
*/
export function normalizeString(str: string): string
{
return str
? str.toLowerCase?.().normalize?.("NFD")
.replace?.(/[\u0300-\u036f]/g, "")
: "";
}
/**
* Get the previous value of a given value.
* @param currentValue The current value.
*/
export function usePreviousValue<T>(currentValue: T): T
{
// Get the reference to the previous value.
const ref = useRef<T>();
// If the value has changed, saving it in the reference after rendering.
useEffect(() => {
ref.current = currentValue;
}, [currentValue]);
// Get the previous value.
return ref?.current ?? undefined;
}
/**
* Create a callback for a form submit function that fully overrides default form behavior.
* @param submitFunction The function to call on form submit.
* @param dependencies Submit function dependencies.
*/
export function useFormSubmit(submitFunction: () => void, dependencies: DependencyList = []): React.FormEventHandler<HTMLFormElement>
{
// Use a memoized callback.
return useCallback((event: React.FormEvent<HTMLFormElement>) => {
// Prevent default behavior, then call submit function.
event.preventDefault();
submitFunction();
return false;
}, [submitFunction, ...dependencies]);
}

View file

@ -1,91 +0,0 @@
:root
{
@background-lightest: #FFFFFF; --background-lightest: @background-lightest;
@background-lighter: #F7F7F7; --background-lighter: @background-lighter;
@background: #EFEFEF; --background: @background;
@background-darker: #E7E7E7; --background-darker: @background-darker;
@background-darkest: #D9D9D9; --background-darkest: @background-darkest;
@neutral: #909090; --neutral: @neutral;
@foreground-lightest: #1F1F1F; --foreground-lightest: @foreground-lightest;
@foreground-lighter: #171717; --foreground-lighter: @foreground-lighter;
@foreground: #0F0F0F; --foreground: @foreground;
@foreground-darker: #080808; --foreground-darker: @foreground-darker;
@foreground-darkest: #000000; --foreground-darkest: @foreground-darkest;
@foreground-shadow: fade(@foreground, 33%); --foreground-shadow: @foreground-shadow;
@curtain-dim: fade(@foreground, 50%); --curtain-dim: @curtain-dim;
@curtain-inset: fade(@foreground, 20%); --curtain-inset: @curtain-inset;
@green-lighter: #22BD12; --green-lighter: @green-lighter;
@green: #1DA90F; --green: @green;
@green-darker: #159308; --green-darker: @green-darker;
@green-background: #A9FFA0; --green-background: @green-background;
@green-background-darker: #86F37E; --green-background-darker: @green-background-darker;
@red-lighter: #E32424; --red-lighter: @red-lighter;
@red: #D01212; --red: @red;
@red-darker: #AF0707; --red-darker: @red-darker;
@red-background: #FFBABA; --red-background: @red-background;
@red-background-darker: #FFA6A6; --red-background-darker: @red-background-darker;
@blue-lighter: #378AFF; --blue-lighter: @blue-lighter;
@blue: #0D6DEE; --blue: @blue;
@blue-darker: #0657C5; --blue-darker: @blue-darker;
@blue-background: #9DC8FF; --blue-background: @blue-background;
@blue-background-darker: #7EA9E1; --blue-background-darker: @blue-background-darker;
@blue-gradient: linear-gradient(33deg, @blue 0%, @blue-lighter 100%), @blue; --blue-gradient: @blue-gradient;
@orange-lighter: #E77220; --orange-lighter: @orange-lighter;
@orange: #D06112; --orange: @orange;
@orange-darker: #BB5308; --orange-darker: @orange-darker;
@orange-background: #FFC599; --orange-background: @orange-background;
@orange-background-darker: #FFB47D; --orange-background-darker: @orange-background-darker;
@pink-lighter: #EF56DF; --pink-lighter: @pink-lighter;
@pink: #CE3EBF; --pink: @pink;
@pink-darker: #B927AB; --pink-darker: @pink-darker;
@pink-background: #FFABF4; --pink-background: @pink-background;
@pink-background-darker: #EF8CE1; --pink-background-darker: @pink-background-darker;
@purple-lighter: #9752FF; --purple-lighter: @purple-lighter;
@purple: #7D2AFF; --purple: @purple;
@purple-darker: #6610EE; --purple-darker: @purple-darker;
@purple-background: #C8A5FF; --purple-background: @purple-background;
@purple-background-darker: #B489F6; --purple-background-darker: @purple-background-darker;
@yellow-lighter: #F8DF3D; --yellow-lighter: @yellow-lighter;
@yellow: #EACD0D; --yellow: @yellow;
@yellow-darker: #D3B803; --yellow-darker: @yellow-darker;
@yellow-background: #FFF195; --yellow-background: @yellow-background;
@yellow-background-darker: #ECDB71; --yellow-background-darker: @yellow-background-darker;
@brown-lighter: #5E2617; --brown-lighter: @brown-lighter;
@brown: #4B190C; --brown: @brown;
@brown-darker: #3B1105; --brown-darker: @brown-darker;
@brown-background: #B6968F; --brown-background: @brown-background;
@brown-background-darker: #A97E74; --brown-background-darker: @brown-background-darker;
@primary-lighter: @blue-lighter; --primary-lighter: @primary-lighter;
@primary: @blue; --primary: @primary;
@primary-darker: @blue-darker; --primary-darker: @primary-darker;
@primary-background: @blue-background; --primary-background: @primary-background;
@primary-background-darker: @blue-background-darker; --primary-background-darker: @primary-background-darker;
@primary-gradient: @blue-gradient; --primary-gradient: @primary-gradient;
@secondary-lighter: @orange-lighter; --secondary-lighter: @secondary-lighter;
@secondary: @orange; --secondary: @secondary;
@secondary-darker: @orange-darker; --secondary-darker: @secondary-darker;
@secondary-background: @orange-background; --secondary-background: @secondary-background;
@secondary-background-darker: @orange-background-darker; --secondary-background-darker: @secondary-background-darker;
@menu-hover: rgba(255, 255, 255, 0.15); --menu-hover: @menu-hover;
@menu-active: rgba(0, 0, 0, 0.125); --menu-active: @menu-active;
}

View file

@ -1,38 +0,0 @@
html, body
{
margin: 0;
padding: 0;
}
html, body, input, button, textarea
{
font-size: 1em;
font-family: var(--sans-serif-fonts);
font-weight: 500;
}
pre, code
{
font-family: var(--monospace-fonts);
}
body
{
background: var(--background);
color: var(--foreground);
}
p
{
margin: 0.75em auto;
line-height: 1.6em;
}
.virtual
{
position: absolute;
display: block;
height: 0;
opacity: 0;
pointer-events: none;
}

View file

@ -1,23 +0,0 @@
@import "components/_box";
@import "components/_button";
@import "components/_card";
@import "components/_curtains";
@import "components/_dates";
@import "components/_errors";
@import "components/_floating";
@import "components/_form";
@import "components/_headings";
@import "components/_link";
@import "components/_list";
@import "components/_loaders";
@import "components/_menus";
@import "components/_modal";
@import "components/_notifications";
@import "components/_pagination";
@import "components/_select";
@import "components/_steps";
@import "components/_steps-counter";
@import "components/_subapp";
@import "components/_table";
@import "components/_tip";

View file

@ -1,14 +0,0 @@
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/source-serif-4";
@import "@fontsource-variable/jetbrains-mono";
:root
{
@sans-serif-fonts: "Manrope Variable", sans-serif;
@serif-fonts: "Source Serif 4 Variable", serif;
@monospace-fonts: "Jetbrains Mono Variable", monospace;
--sans-serif-fonts: @sans-serif-fonts;
--serif-fonts: @serif-fonts;
--monospace-fonts: @monospace-fonts;
}

View file

@ -1,6 +0,0 @@
footer
{
margin: 4em auto;
color: var(--foreground-lightest);
text-align: center;
}

View file

@ -1,12 +0,0 @@
div.box
{
margin: 0.5em auto;
width: 50em;
max-width: 95%;
box-sizing: border-box;
h1, h2, h3, h4, h5, h6
{
margin: 0.33em 0 0.5em 0;
}
}

View file

@ -1,198 +0,0 @@
a.button, button, input[type="submit"], input[type="reset"]
{
transition: background 0.2s ease, outline 0.2s ease, transform 0.1s ease;
display: inline-block;
margin: 0.2em 0.33em;
padding: 0.45em 0.66em;
width: fit-content;
border-radius: 0.25em;
box-sizing: border-box;
box-shadow: 0 0 0.3em 0 var(--foreground-shadow);
outline: solid 2px transparent;
outline-offset: 2px;
border: solid var(--primary-darker) thin;
background: var(--primary);
color: var(--background);
font-weight: 600;
text-decoration: none;
vertical-align: middle;
text-align: center;
cursor: pointer;
transform: scale(1);
transform-origin: center;
&:hover
{
background: var(--primary-lighter);
}
&:focus
{
outline: solid 2px var(--primary);
outline-offset: 2px;
}
&:active
{
transform: scale(0.95);
}
&:disabled
{
opacity: 60%;
}
&.flat
{
box-shadow: 0 0 0 0 transparent;
border-color: var(--background-darkest);
background: var(--background-lighter);
color: var(--foreground);
&:hover
{
background: var(--background-lightest);
}
&:focus
{
outline-color: var(--neutral);
}
}
&.green, &.validation, &.ok, &.save, &.positive, &.good, &.success
{
border-color: var(--green-darker);
background: var(--green);
&:hover
{
background: var(--green-lighter);
}
&:focus
{
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, &.warning
{
border-color: var(--orange-darker);
background: var(--orange);
&:hover
{
background: var(--orange-lighter);
}
&:focus
{
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, &.remove, &.close, &.no, &.negative, &.bad, &.error
{
border-color: var(--red-darker);
background: var(--red);
&:hover
{
background: var(--red-lighter);
}
&:focus
{
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
{ // Icon style.
display: inline-block;
margin-top: -0.2em;
margin-right: 0.2em;
vertical-align: middle;
}
&.icon-only svg
{
margin-right: 0.05em;
}
}
div.buttons
{
display: flex;
flex-direction: row;
gap: 0.5em;
margin: 0.25em auto;
&.right
{
justify-content: flex-end;
}
&.left
{
justify-content: flex-start;
}
&.center
{
justify-content: center;
}
button, .button
{
margin: 0;
}
}

View file

@ -1,30 +0,0 @@
.card
{
margin: 0.5em auto;
padding: 1.4em;
width: 50em;
max-width: 92%;
box-sizing: border-box;
border-radius: 0.25em;
border: solid var(--background-darkest) thin;
background: var(--background-lighter);
h1, h2, h3, h4, h5, h6
{
margin: 0.33em 0 0.5em 0;
}
> p
{
&:first-child
{ margin-top: auto; }
&:last-child
{ margin-bottom: auto; }
}
&.floating
{
width: 20em;
}
}

View file

@ -1,57 +0,0 @@
body > .curtain
{ // Position the curtain on top of everything and dim the background.
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
box-shadow: inset 0 0 5em 0 var(--curtain-inset);
background: var(--curtain-dim);
// Show an animation when entering screen.
animation: curtain-in 0.4s ease-in;
transform-origin: center;
z-index: 1000; // On top of main content.
&.closed
{ // Added when the curtain is closing and will soon be removed from DOM.
transition: transform 0.4s ease-out, filter 0.4s ease-out, opacity 0.4s ease-out;
transform: scale(1.2);
filter: blur(0.5em);
opacity: 0;
pointer-events: none;
}
}
body.dimmed
{ // Disable scroll and blur the content when the body is dimmed.
overflow: hidden;
> *:not(.curtain):not(.notifications)
{
transition: filter 0.4s ease-in;
filter: blur(0.25em);
}
}
@keyframes curtain-in
{ // Screen enter animation.
from
{
transform: scale(1.2);
filter: blur(0.5em);
opacity: 0;
}
to
{
transform: scale(1);
filter: blur(0);
opacity: 1;
}
}

View file

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

View file

@ -1,60 +0,0 @@
main.error
{
font-size: 1.2em;
> svg
{
display: block;
margin: 1em auto;
color: var(--red);
}
hr
{
margin: 1em auto;
width: 20em;
max-width: 50%;
height: 0.25em;
border-radius: 0.1em;
border: none;
background: var(--red);
}
h1, h2, h3, h4, h5, h6
{
margin: auto;
text-align: center;
}
p, pre
{
margin: 1em auto;
width: 40em;
max-width: 95%;
text-align: center;
}
button
{
display: block;
margin: 1em auto;
padding: 0.66em 1em;
}
.details
{
font-size: 0.8em;
button
{}
pre
{
width: auto;
text-align: left;
}
}
}

View file

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

View file

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

View file

@ -1,49 +0,0 @@
h1, h2, h3, h4, h5, h6
{
margin: 0.8em auto;
&.center, &.main
{ text-align: center; }
> svg
{
position: relative;
bottom: -0.15em;
}
}
h1
{
font-size: 2.5em;
font-weight: 800;
}
h2
{
font-size: 2em;
font-weight: 800;
}
h3
{
font-size: 1.7em;
font-weight: 700;
}
h4
{
font-size: 1.5em;
font-weight: 600;
}
h5
{
font-size: 1.3em;
font-weight: 600;
}
h6
{
font-size: 1.15em;
font-weight: 600;
}

View file

@ -1,4 +0,0 @@
a
{
color: var(--primary);
}

View file

@ -1,15 +0,0 @@
ul
{
list-style: "";
padding: 0 2em;
> li
{
padding: 0 0 0 0.5em;
}
}
ul, ol
{
margin: 1em auto;
}

View file

@ -1,5 +0,0 @@
@import "loaders/_animated-background";
@import "loaders/_generic";
@import "loaders/_list";
@import "loaders/_spinning";

View file

@ -1,3 +0,0 @@
@import "menus/_apps-menu";
@import "menus/_main-menu";
@import "menus/_submenu";

View file

@ -1,99 +0,0 @@
.curtain > .modal
{
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: 45em; max-width: 95%;
height: fit-content; max-height: 95%;
border-radius: 0.25em;
box-sizing: border-box;
background: var(--background);
overflow: auto;
> header
{
display: flex;
flex-direction: row;
align-items: center;
> h1
{
flex: 1;
margin: auto;
padding: 1em 1em;
font-size: 1.33em;
font-weight: 650;
vertical-align: middle;
svg
{
margin-top: -0.2em;
margin-right: 0.1em;
vertical-align: middle;
}
}
> .close
{
transition: opacity 0.2s ease;
width: 2em;
height: 2em;
margin: auto 1em;
padding: 0;
border-radius: 2em;
box-shadow: 0 0 0 0 transparent;
outline: none;
border: none;
background: none;
color: var(--foreground-lightest);
opacity: 0.6;
&:hover
{
opacity: 1;
}
&:disabled
{
opacity: 0.33;
}
}
}
> main
{
padding: 1em;
}
&.info
{
> header > h1 { color: var(--primary); }
}
&.success
{
> header > h1 { color: var(--green); }
}
&.warning
{
> header > h1 { color: var(--orange); }
}
&.error
{
> header > h1 { color: var(--red); }
}
}

View file

@ -1,86 +0,0 @@
body > ul.notifications
{ // Notifications list.
position: fixed;
top: 0;
right: 0;
margin: 0 1em 1em;
padding: 0;
width: 20em;
max-width: 66%;
list-style: none;
z-index: 2000;
> li.notification
{ // A single notification.
margin: 1em auto;
padding: 1em;
border-radius: 0.25em;
box-shadow: 0 0 0.5em 0 var(--foreground-shadow);
background: var(--background);
// Show an animation when entering screen.
animation: notification-in 0.3s ease-in;
transform-origin: center;
&.closed
{ // Added when the notification is closing and will soon be removed from DOM.
transition: transform 0.3s ease-out, filter 0.3s ease-out, opacity 0.3s ease-out;
transform: scale(1.15);
filter: blur(0.25em);
opacity: 0;
pointer-events: none;
}
&.info
{
border: solid var(--primary-darker) thin;
background: var(--primary);
color: var(--background);
}
&.success
{
border: solid var(--green-darker) thin;
background: var(--green);
color: var(--background);
}
&.warning
{
border: solid var(--orange-darker) thin;
background: var(--orange);
color: var(--background);
}
&.error
{
border: solid var(--red-darker) thin;
background: var(--red);
color: var(--background);
}
}
}
@keyframes notification-in
{ // Screen enter animation.
from
{
transform: scale(1.15);
filter: blur(0.25em);
opacity: 0;
}
to
{
transform: scale(1);
filter: blur(0);
opacity: 1;
}
}

View file

@ -1 +0,0 @@
@import "pagination/_nav";

View file

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

View file

@ -1,24 +0,0 @@
.steps-counter
{
counter-reset: steps-count 0;
.step
{
&::before
{
content: counter(steps-count);
display: inline-block;
margin: 0 0.2em;
color: var(--primary);
font-family: var(--monospace-fonts);
font-size: 1.5em;
font-weight: 700;
vertical-align: baseline;
}
counter-increment: steps-count;
}
}

View file

@ -1,90 +0,0 @@
div.steps
{
display: flex;
flex-direction: row;
> nav.steps
{
position: sticky;
top: 1em;
bottom: 1em;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: auto 0.5em;
> ul
{
margin: 0;
padding: 0;
list-style: none;
> li
{
margin: auto;
padding: 0;
text-align: center;
> button
{
padding: 0;
box-shadow: 0 0 0 0 transparent;
outline: none;
border: none;
background: none;
color: var(--foreground-lightest);
&:focus::before
{
outline-color: var(--primary);
}
&::before
{
transition: all 0.2s ease;
content: "";
display: block;
margin: auto auto 0.4em auto;
width: 2em;
height: 2em;
border-radius: 2em;
box-shadow: 0 0 0 0 transparent;
outline: solid 2px transparent;
outline-offset: 2px;
border: solid var(--background-darkest) thin;
background: var(--background-lightest);
transform: scale(0.75);
}
}
&.active
{
> button
{
&::before
{
box-shadow: 0 0 0.3em 0 var(--foreground-shadow);
border-color: var(--primary-darker);
background: var(--primary);
transform: scale(1);
}
}
}
}
}
}
> :not(nav)
{
flex: 1;
}
}

View file

@ -1,76 +0,0 @@
@subapp-margin: 1.5em;
.curtain > .subapp
{
position: absolute;
top: @subapp-margin;
left: @subapp-margin;
display: flex;
flex-direction: column;
width: calc(100% - (2 * @subapp-margin));
height: calc(100% - (2 * @subapp-margin));
border-radius: 0.25em;
box-sizing: border-box;
background: var(--background);
overflow: auto;
> header
{
display: flex;
flex-direction: row;
align-items: center;
> h1
{
flex: 1;
margin: auto;
padding: 1em 1em;
font-size: 1.66em;
font-weight: 650;
}
> .close
{
transition: opacity 0.2s ease;
width: 3em;
height: 3em;
margin: auto 1em;
padding: 0;
border-radius: 2em;
box-shadow: 0 0 0 0 transparent;
outline: none;
border: none;
background: none;
color: var(--foreground-lightest);
opacity: 0.6;
&:hover
{
opacity: 1;
}
&:disabled
{
opacity: 0.33;
}
}
}
> main
{
flex: 1;
padding: 1em;
overflow: auto;
}
}

View file

@ -1,44 +0,0 @@
table
{
margin: auto;
border-collapse: collapse;
background: var(--background-lighter);
//border: solid var(--background-darkest) thin;
border-radius: 0.25em;
thead
{
}
tbody
{
tr
{
&:nth-child(even)
{
background: var(--background-lightest);
}
&:hover
{
background: var(--background-darker);
}
&:last-child { border-bottom: none; }
}
}
tr
{
transition: background 0.1s ease;
border-bottom: solid var(--background-darkest) thin;
background: transparent;
}
th, td
{
padding: 0.4em 0.6em;
text-align: left;
}
}

View file

@ -1,28 +0,0 @@
p.tip
{
position: relative;
margin: auto;
width: 30em;
padding: 0.3em 0.5em 0.3em 3em;
border-radius: 0.3em;
box-sizing: border-box;
border: solid var(--background-darkest) thin;
color: var(--foreground-lightest);
svg
{
position: absolute;
top: 0.3em;
left: 0.5em;
display: inline-block;
margin: 0;
color: var(--primary);
font-size: 1.5em;
vertical-align: middle;
}
}

View file

@ -1,57 +0,0 @@
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

@ -1,44 +0,0 @@
.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

@ -1,12 +0,0 @@
:not(.card).floating
{
display: flex;
align-self: center;
justify-content: center;
z-index: 1;
> .card.floating
{
margin: 0.4em;
}
}

View file

@ -1,13 +0,0 @@
.card.floating.tooltip
{
width: unset;
max-width: 20em;
padding: 0.5em;
border-color: var(--foreground-darker);
background: var(--foreground-lightest);
color: var(--background);
font-size: 0.9em;
text-align: center;
}

View file

@ -1,84 +0,0 @@
label.box, label.toggleswitch
{
display: block;
&::before
{
content: unset;
}
> input
{
position: absolute;
pointer-events: none;
opacity: 0;
}
> input[type="radio"] + .button
{
border-radius: 50%;
}
> .button
{
margin: 0 0.6em 0.12em 0;
width: 1.5em;
height: 1.5em;
padding: 0;
border: none;
color: var(--background-darkest);
> svg
{
display: block;
margin: 0.22em auto 0;
vertical-align: middle;
}
vertical-align: middle;
}
&:focus-within > .button
{
outline-color: var(--primary);
}
> input:not(:checked) + .button
{
background: var(--background-lighter);
}
}
label.toggleswitch
{
> .button
{
display: inline-flex;
align-items: center;
width: 2.5em;
> .switch
{
transition: margin-left 0.25s ease, box-shadow 0.25s ease;
display: inline-block;
margin: auto 0.25em;
width: 0.9em;
height: 0.8em;
border-radius: 0.25em;
box-shadow: 0 0 0 0 var(--foreground-shadow);
border: solid var(--background-darkest) thin;
background: var(--background);
}
}
> input:checked + .button > .switch
{
margin-left: 1.2em;
border-color: transparent;
box-shadow: 0 0 0.4em 0 var(--foreground-shadow);
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more