master
Sven Slootweg 4 years ago
commit 63fee8ca26

@ -0,0 +1,3 @@
{
"extends": "@joepie91/eslint-config/react"
}

1
.gitignore vendored

@ -0,0 +1 @@
node_modules

@ -0,0 +1,7 @@
# FIXME
- Figure out a way to make this automatically load the correct PostCSS plugins, when used with icssify (since we probably don't want to pre-compile the CSS?)
# Name ideas
- SnapUI (snap-ui)

@ -0,0 +1,49 @@
{
"name": "ui-lib",
"description": "A highly-responsive library of UI controls, as React components",
"version": "0.1.0",
"main": "src/index.js",
"repository": "http://git.cryto.net/joepie91/ui-lib.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "WTFPL OR CC0-1.0",
"devDependencies": {
"@babel/core": "^7.11.4",
"@babel/preset-env": "^7.11.0",
"@babel/preset-react": "^7.10.4",
"@joepie91/eslint-config": "^1.1.0",
"babelify": "^10.0.0",
"eslint": "^6.7.0",
"eslint-plugin-react": "^7.16.0",
"eslint-plugin-react-hooks": "^2.3.0",
"icssify": "^1.2.1"
},
"dependencies": {
"@react-hook/debounce": "^3.0.0",
"as-expression": "^1.0.0",
"assure-array": "^1.0.0",
"classnames": "^2.2.6",
"default-value": "^1.0.0",
"flatten": "^1.0.3",
"match-value": "^1.1.0",
"nanoid": "^3.1.12",
"react": "link:../site-builder/node_modules/react",
"react-use-measure": "^2.0.1",
"syncpipe": "^1.0.0"
},
"browserify": {
"transform": [
[
"babelify",
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
]
],
"plugin": [
"icssify"
]
}
}

@ -0,0 +1,5 @@
"use strict";
const React = require("react");
module.exports = React.createContext();

@ -0,0 +1,5 @@
"use strict";
const React = require("react");
module.exports = React.createContext();

@ -0,0 +1,5 @@
"use strict";
const React = require("react");
module.exports = React.createContext([]);

@ -0,0 +1,5 @@
"use strict";
const React = require("react");
module.exports = React.createContext();

@ -0,0 +1,26 @@
"use strict";
const React = require("react");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
const Icon = require("../icon");
module.exports = function Button({ type, onClick, label, icon }) {
// TODO: Validate type?
// TODO: Content override instead of label/icon?
let { withTheme } = useTheme({ control: "button", defaultStyle });
return (
<button className={withTheme("button", `style-${type}`)} onClick={onClick}>
<span className="buttonContents">
{(icon != null)
? <Icon icon={icon} />
: null
}
{label}
</span>
</button>
);
};

@ -0,0 +1,3 @@
/* This file contains dummy classes that are needed to work around the lack of support for circular imports (eg. for recursively nested controls) */
.menu {}

@ -0,0 +1,5 @@
/* NOTE: The 'glo-bal' filename of this file is a workaround to deal with `insert-module-globals` incorrectly detecting this file to contain a reference to a `g lobal` variable (due to the icssify-generated class names including the filename) */
.uilibComponent {
font-family: sans-serif;
}

@ -0,0 +1,16 @@
'use strict';
const React = require("react");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
module.exports = function Icon({ icon }) {
// FIXME: Figure out a better way to test stuff than symlinking ui-lib react into site-builder react...
let { withTheme } = useTheme({ control: "icon", defaultStyle });
// FIXME: Don't hardcode icon base path, get it from theme
return (
<img className={withTheme("icon")} src={`/images/icons/${icon}.svg`} />
);
};

@ -0,0 +1,3 @@
.icon {
/* placeholder */
}

@ -0,0 +1,95 @@
"use strict";
const React = require("react");
const asExpression = require("as-expression");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
const ListItemContext = require("../../contexts/list-item");
const comparePath = require("../../util/compare-path");
const useClickTimer = require("../../util/use-click-timer");
function Expander({ expanded, onClick }) {
let { withTheme } = useTheme({ control: "list", defaultStyle });
return (
<div className={withTheme("expander")} onClick={onClick}>
{(expanded === true)
? "▼"
: "▶"}
</div>
);
}
function ExpanderPlaceholder() {
let { withTheme } = useTheme({ control: "list", defaultStyle });
return <div className={withTheme("expanderPlaceholder")} />;
}
module.exports = function ListItem({ label, id, children }) {
let { onClick, onDoubleClick, path, selectedPath } = React.useContext(ListItemContext);
let [ expanded, setExpanded ] = React.useState(true);
let { withTheme } = useTheme({ control: "list", defaultStyle });
let doubleClickTimer = useClickTimer(500);
let depth = path.length;
let indent = depth * 11; // FIXME: Figure out a way to do this with CSS instead, eg. via value exports?
let ownPath = path.concat([ id ]);
let isSelected = comparePath(selectedPath, ownPath);
function handleClick() {
if (doubleClickTimer.click()) {
if (onDoubleClick != null) {
onDoubleClick(ownPath);
} else if (children != null) {
// TODO: Check whether we really only want to do this when no onDoubleClick handler is specified, ie. only as a default behaviour
toggleExpanded();
}
}
}
function handleMouseDown() {
if (doubleClickTimer.mouseDown()) {
onClick(ownPath);
}
}
function toggleExpanded() {
setExpanded((expanded) => !expanded);
}
let style = {
paddingLeft: `${indent}px`
};
return (
<React.Fragment>
{/* FIXME: item_selected capitalization */}
<div className={withTheme(["item", { item_selected: isSelected }])} style={style} onMouseDown={handleMouseDown} onClick={handleClick}>
{(children != null)
? <Expander expanded={expanded} onClick={toggleExpanded} />
: <ExpanderPlaceholder />}
<div className={withTheme("label")}>
{label}
</div>
</div>
{(children != null && expanded === true)
? asExpression(() => {
let itemContext = {
path: ownPath,
selectedPath: selectedPath,
onClick: onClick,
onDoubleClick: onDoubleClick
};
return (
<ListItemContext.Provider value={itemContext}>
{children}
</ListItemContext.Provider>
);
})
: null}
</React.Fragment>
);
};

@ -0,0 +1,30 @@
:import("../icon/style.css") {
icon: icon;
}
.item {
font-size: 12px;
padding: 4px 6px;
.icon {
position: relative;
top: 1px;
margin-right: 6px;
}
}
.expander, .expanderPlaceholder, .label {
display: inline-block;
}
.expander, .expanderPlaceholder {
width: 12px;
height: 12px;
margin-right: 4px;
vertical-align: 2px;
}
.expander {
text-align: center;
font-size: 10px;
}

@ -0,0 +1,39 @@
'use strict';
const React = require("react");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
const ListItemContext = require("../../contexts/list-item");
// FIXME: Track full collapsing state throughout the tree
module.exports = function List({ children, onPick, onSelect }) {
let [ selectedItem, setSelectedItem ] = React.useState([]);
let { withTheme } = useTheme({ control: "list", defaultStyle });
let itemContext = {
path: [],
selectedPath: selectedItem,
onClick: function (path) {
if (onSelect != null) {
onSelect(path);
}
setSelectedItem(path);
},
onDoubleClick: function (path) {
if (onPick != null) {
onPick(path);
}
}
};
return (
<ListItemContext.Provider value={itemContext}>
<div className={withTheme("list")}>
{children}
</div>
</ListItemContext.Provider>
);
};

@ -0,0 +1,7 @@
.list {
/* @include edge-lower; */
overflow-y: auto;
cursor: default;
user-select: none;
-moz-user-select: none;
}

@ -0,0 +1,53 @@
"use strict";
const React = require("react");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
const isEventInsideRef = require("../../util/is-event-inside-ref");
const MenuItemContext = require("../../contexts/menu-item");
module.exports = function MenuBar({ style, children }) {
let { withTheme } = useTheme({ control: "menu", defaultStyle });
let [ isActive, setIsActive ] = React.useState(false);
let [ selectedItem, setSelectedItem ] = React.useState([]);
let element = React.useRef();
React.useEffect(() => {
function handleGlobalClick(event) {
if (!isEventInsideRef(element, event)) {
setIsActive(false);
}
}
document.addEventListener("click", handleGlobalClick);
return () => {
document.removeEventListener("click", handleGlobalClick);
};
}, []);
let itemContext = {
menuType: "menuBar",
selectedPath: selectedItem,
isActive: isActive,
onMouseEnter: (path) => {
setSelectedItem(path);
},
onClick: (_path, hasMenu, isInMenu) => {
// Only activate the menu bar if this *isn't* a direct-action button directly on the menubar
if (hasMenu || isInMenu) {
// NOTE: This is a toggle that sets isActive for the *whole* menu bar rather than an individual item, because pressing *any* button in the menu will trigger menu-display mode; after that, merely hovering over other buttons is enough to switch what menu is displayed.
setIsActive((isActive) => !isActive);
}
}
};
return (
<MenuItemContext.Provider value={itemContext}>
<div className={withTheme("menuBar")} style={style} ref={element}>
{children}
</div>
</MenuItemContext.Provider>
);
};

@ -0,0 +1,24 @@
:import("../menu-item/style.css") {
item: item;
submenu: submenu;
submenuArrow: submenuArrow;
}
.menuBar {
composes: notSelectable from "../shared.css";
display: flex;
}
.menuBar > .item {
padding: 4px 7px 5px 7px;
}
.menuBar > .item > .submenu > .submenuArrow {
display: none;
}
.menuBar .icon {
color: red; /* ??? */
height: 16px;
margin-top: 2px;
}

@ -0,0 +1,94 @@
'use strict';
const React = require("react");
const useMeasure = require("react-use-measure");
const matchValue = require("match-value");
const insecureNanoid = require("nanoid/non-secure").nanoid;
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
const useGuaranteedMemo = require("../../util/use-guaranteed-memo");
const isEventInsideRef = require("../../util/is-event-inside-ref");
const comparePath = require("../../util/compare-path");
const MenuItemContext = require("../../contexts/menu-item");
const MenuPositionContext = require("../../contexts/menu-position");
const MenuPathContext = require("../../contexts/menu-path");
module.exports = function MenuItem({ label, title, menu, onClick }) {
let { withTheme } = useTheme({ control: "menu", defaultStyle });
let [ boundsRef, bounds ] = useMeasure({ debounce: 10 });
let submenuRef = React.useRef();
let itemContext = React.useContext(MenuItemContext);
let path = React.useContext(MenuPathContext);
let id = useGuaranteedMemo(() => insecureNanoid()); // insecure is fine here, these IDs are not interesting to an attacker
let ownPath = path.concat([ id ]); // FIXME: memoize?
let isSelected = itemContext.isActive && comparePath(itemContext.selectedPath, ownPath);
let submenuPosition = matchValue(itemContext.menuType, {
menuBar: {
x: bounds.x,
y: bounds.y + bounds.height
},
menu: {
x: bounds.x + bounds.width, /* FIXME: Account for border of containing menu? */
y: bounds.y
}
});
let handlers = {
onClick: (event) => {
if (!isEventInsideRef(submenuRef, event)) {
if (onClick != null) {
onClick(); // TODO: Pass through event? Allow cancellation? To deal with advanced cases like checkboxes within menus, if we want that
}
if (itemContext.onClick != null) {
let hasMenu = (menu != null);
let isInMenu = false; // Let the menu intercept and change this
itemContext.onClick(ownPath, hasMenu, isInMenu);
}
}
},
onMouseEnter: (_event) => {
// FIXME: Only outside of submenu?
if (itemContext.onMouseEnter != null) {
itemContext.onMouseEnter(ownPath);
}
},
onMouseLeave: (_event) => {
// FIXME: Only outside of submenu?
if (itemContext.onMouseLeave != null) {
itemContext.onMouseLeave(ownPath);
}
},
};
let buttonClasses = withTheme([
"item",
{ item_selected: isSelected },
{ item_directPress: (menu == null) }
]);
return (
<MenuPathContext.Provider value={ownPath}>
<MenuPositionContext.Provider value={submenuPosition}>
<div ref={boundsRef} title={title} className={buttonClasses} {...handlers}>
{label}
{(menu != null)
? <span className={withTheme("submenu")} ref={submenuRef}>
<i className={withTheme("submenuArrow")}></i>
{(isSelected)
? menu
: null
}
</span>
: null
}
</div>
</MenuPositionContext.Provider>
</MenuPathContext.Provider>
);
};

@ -0,0 +1,19 @@
:import("../dummy-classes.css") {
menu: menu;
}
.item .menu {
position: fixed;
z-index: 99999;
}
.submenu {
/* Placeholder */
}
.submenuArrow {
float: right;
font-style: normal;
margin-left: .8em;
font-size: .8em;
}

@ -0,0 +1,54 @@
"use strict";
const React = require("react");
const { useDebounce } = require("@react-hook/debounce");
const MenuItemContext = require("../../contexts/menu-item");
const MenuPositionContext = require("../../contexts/menu-position");
const useTheme = require("../../util/themeable");
const defaultStyle = require("./style.css");
// FIXME: Use flexbox to deal with overflow of long menus
module.exports = function Menu({ children }) {
let { withTheme } = useTheme({ control: "menu", defaultStyle });
let parentItemContext = React.useContext(MenuItemContext);
let menuPosition = React.useContext(MenuPositionContext);
// FIXME: We don't want a debounce, but a delay timer for submenu (dis)appearance? Separate from hover effect for the menu item itself. Debounce is insufficient because if one keeps moving the submenu never disappears
let [ selectedItem, setSelectedItem ] = useDebounce([], 150);
let itemContext = {
menuType: "menu",
selectedPath: selectedItem,
isActive: parentItemContext.isActive,
onMouseEnter: (path) => {
setSelectedItem(path);
},
onClick: (path, hasMenu, _isInMenu) => {
// Items with submenus should not be considered clickable
if (!hasMenu) {
parentItemContext.onClick(path, false, true);
}
}
};
// FIXME: Is there ever a case where this *can* validly be null?
let style = (menuPosition != null)
? {
left: menuPosition.x,
top: menuPosition.y
}
: null;
if (parentItemContext.isActive) {
return (
<MenuItemContext.Provider value={itemContext}>
<div className={withTheme("menu")} style={style}>
{children}
</div>
</MenuItemContext.Provider>
);
} else {
return null;
}
};

@ -0,0 +1,26 @@
:import("../menu-item/style.css") {
item: item;
}
:import("../dummy-classes.css") {
menu_dummy: menu;
}
.menu {
/* TODO: Is there also a compose-multiple syntax for remote compose? */
/* HACK: We cannot compose directly inside of an imported class (is this an ICSS bug?), so instead we create a new class and compose the dummy class into it, to tie the two together */
composes: menu_dummy;
composes: notSelectable from "../shared.css";
min-width: 150px;
padding: 1px;
}
.menu > .item {
padding: 4px 9px;
}
.menu hr {
color: none;
border: none;
margin: 4px 4px;
}

@ -0,0 +1,16 @@
"use strict";
const React = require("react");
const defaultStyle = require("./style.css");
const useTheme = require("../../util/themeable");
module.exports = function Ribbon({ children }) {
let { withTheme } = useTheme({ control: "ribbon", defaultStyle });
return (
<div className={withTheme("ribbon")}>
{children}
</div>
);
};

@ -0,0 +1,4 @@
.ribbon {
composes: notSelectable from "../shared.css";
display: flex;
}

@ -0,0 +1,17 @@
.notSelectable {
cursor: default;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-webkit-touch-callout: none;
-khtml-user-select: none;
-ms-user-select: none;
}
.centerContent {
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
}

@ -0,0 +1,44 @@
"use strict";
module.exports = {
/* Multi-purpose controls */
Icon: require("./controls/icon/index.jsx"),
// Text: require("./controls/text.jsx"),
// /* Pane layout */
// PaneLayout: require("./controls/pane-layout/layout.jsx"),
// PaneRow: require("./controls/pane-layout/row.jsx"),
// PaneTabSet: require("./controls/pane-layout/tabset.jsx"),
// PaneTab: require("./controls/pane-layout/tab.jsx"),
// /* Menu */
Menu: require("./controls/menu/index.jsx"),
MenuBar: require("./controls/menu-bar/index.jsx"),
MenuItem: require("./controls/menu-item/index.jsx"),
// MenuDivider: require("./controls/menu/divider.jsx"),
// /* List */
List: require("./controls/list/index.jsx"),
ListItem: require("./controls/list-item/index.jsx"), // NOTE: This will also be used for other types of lists (eg. dropdowns)
// /* Ribbon */
// Ribbon: require("./controls/ribbon/ribbon.jsx"),
// RibbonBox: require("./controls/ribbon/box.jsx"),
// // RibbonItem: require("./ribbon/item.jsx"), // Used internally, but not exported (yet)
// RibbonButton: require("./controls/ribbon/button.jsx"),
// RibbonButtonSet: require("./controls/ribbon/button-set.jsx"),
// RibbonListBox: require("./controls/ribbon/list-box.jsx"),
// RibbonListButton: require("./controls/ribbon/list-button.jsx"),
// RibbonProgressBar: require("./controls/ribbon/progress-bar.jsx"),
// RibbonProgressButton: require("./controls/ribbon/progress-button.jsx"),
// RibbonStatusIndicator: require("./controls/ribbon/status-indicator.jsx"),
// RibbonText: require("./controls/ribbon/text.jsx")
SetTheme: require("./util/themeable/set-theme"),
themes: {
dark: {
getIcon: (name) => `/icons/${name}.svg`,
css: require("./themes/dark.css")
}
}
};

@ -0,0 +1,75 @@
$darkGray: rgb(34, 34, 34);
$hoverColor: rgba(113, 113, 113, 0.26);
$activeColor: $darkGray;
.edgeRaise {
box-shadow: inset -1px -1px rgba(30, 27, 27, 0.7),
inset 1px 1px rgba(135, 131, 131, 0.3);
}
.edgeLower {
box-shadow: inset 1px 1px rgba(30, 27, 27, 0.7),
inset -1px -1px rgba(135, 131, 131, 0.3);
}
.bar {
composes: edgeRaise;
background-color: rgb(60, 62, 66);
}
.list_list {
/* color: red; */
}
.list_item {
color: white;
&:nth-child(odd) {
/* background-color: rgb(45, 45, 45); */
/* background-color: rgb(51, 51, 51); */
background-color: rgb(57, 57, 60);
}
&:nth-child(even) {
background-color: rgb(27, 27, 27);
}
&.list_item_selected {
/* FIXME: Find a better color for this. */
/* background-color: rgb(38, 38, 42); */
background-color: blue; /* FIXME: Remove testing color */
}
}
.menu_menuBar {
composes: bar;
}
.menu_menuBar > .menu_item {
color: white;
&:hover {
background-color: $hoverColor;
}
&.menu_item_selected, &menu_item_directPress {
background-color: $activeColor;
}
}
.menu_menu {
background-color: $darkGray;
box-shadow: 1px 1px 2px rgb(54, 54, 54);
& > .menu_item:hover {
background-color: $hoverColor;
}
hr {
border-bottom: 1px solid rgb(78, 78, 78);
}
}
.ribbon_ribbon {
composes: bar;
}

@ -0,0 +1,30 @@
"use strict";
module.exports = function createClickTimer({ timeout }) {
let timer;
// NOTE: We have separate handling for mouseDown and click events, so that even if the user double-clicks, the response to initial click feels instant.
return {
mouseDown: function () {
// returns: is first click
return (timer == null);
},
click: function () {
// returns: is double click
if (timer == null) {
timer = setTimeout(() => {
timer = null;
}, timeout);
return false;
} else {
timer = null;
return true;
}
},
isRunning: function () {
return (timer != null);
}
};
};

@ -0,0 +1,15 @@
"use strict";
module.exports = function comparePath(a, b) {
if (a.length !== b.length) {
return false;
} else {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
};

@ -0,0 +1,30 @@
"use strict";
const assureArray = require("assure-array");
const syncpipe = require("syncpipe");
const flatten = require("flatten");
module.exports = function extendClassnames(classes, mapper) {
return assureArray(classes).map((item) => {
if (typeof item === "string") {
return mapper(item);
} else if (Array.isArray(item)) {
return item.map((subItem) => {
return mapper(subItem);
});
} else {
// { className: condition } mapping
return syncpipe(item, [
(_) => Object.entries(_),
(_) => _.map(([ key, value ]) => syncpipe(key, [
(_) => mapper(_),
(_) => assureArray(_),
(_) => _.filter((className) => className != null),
(_) => _.map((className) => [ className, value ])
])),
(_) => flatten(_, 1),
(_) => Object.fromEntries(_),
]);
}
});
};

@ -0,0 +1,5 @@
"use strict";
module.exports = function isEventInsideRef(ref, event) {
return (ref.current != null && ref.current.contains(event.target));
};

@ -0,0 +1,5 @@
"use strict";
const React = require("react");
module.exports = React.createContext();

@ -0,0 +1,48 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const defaultValue = require("default-value");
const extendClassnames = require("../extend-classnames");
const ThemeContext = require("./context");
const globalStyle = require("../../controls/glo-bal-style.css");
function mapThemeName(themeCSS, className, rulePrefix) {
return themeCSS[`${rulePrefix}${className}`];
}
function mapDefaultName(defaultStyle, className) {
return defaultStyle[className];
}
module.exports = function useTheme({ control, defaultStyle }) {
let theme = React.useContext(ThemeContext);
let rulePrefix = (control != null)
? `${control}_`
: "";
function withTheme(classes) {
if (arguments.length > 1) {
console.warn(`WARNING: More than one argument specified to 'withTheme' call (second argument is ${JSON.stringify(arguments[1])})`);
}
let mappedClasses = extendClassnames(classes, (className) => {
return [
mapDefaultName(defaultValue(defaultStyle, {}), className),
(theme != null)
? mapThemeName(theme.css, className, rulePrefix)
: null,
mapDefaultName(globalStyle, "uilibComponent"),
(theme != null)
? mapThemeName(theme.css, "uilibComponent", "")
: null,
];
});
return classnames(... mappedClasses);
}
return { theme, withTheme };
};

@ -0,0 +1,13 @@
"use strict";
const React = require("react");
const ThemeContext = require("./context");
module.exports = function SetTheme({ theme, children }) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
};

@ -0,0 +1,9 @@
"use strict";
const useLazyRef = require("./use-lazy-ref");
const createClickTimer = require("./click-timer");
module.exports = function useClickTimer(timeout) {
let ref = useLazyRef(() => createClickTimer({ timeout: timeout }));
return ref.current;
};

@ -0,0 +1,14 @@
"use strict";
const React = require("react");
// Unlike React.useMemo, this one is guaranteed to never drop its value. Useful for things like stable ID generation.
module.exports = function useGuaranteedMemo(initializer, dependencies = []) {
let [ value, setValue ] = React.useState();
React.useEffect(() => {
setValue(initializer);
}, dependencies);
return value;
};

@ -0,0 +1,18 @@
"use strict";
const React = require("react");
module.exports = function useLazyRef(initializer) {
let ref = React.useRef({ initialized: false, value: undefined });
if (ref.current.initialized === false) {
ref.current.value = {
// We mirror the standard useRef structure here
current: initializer()
};
ref.current.initializer = true;
}
return ref.current.value;
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save