First shlink-frontend-kit iteration

This commit is contained in:
Alejandro Celaya
2023-07-31 21:36:44 +02:00
parent 5ec5396da6
commit 99ce8c9f74
102 changed files with 152 additions and 168 deletions

View File

@@ -0,0 +1,54 @@
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import type { FC, PropsWithChildren } from 'react';
import { Card, Row } from 'reactstrap';
type MessageType = 'default' | 'error';
const getClassForType = (type: MessageType) => {
const map: Record<MessageType, string> = {
error: 'border-danger',
default: '',
};
return map[type];
};
const getTextClassForType = (type: MessageType) => {
const map: Record<MessageType, string> = {
error: 'text-danger',
default: 'text-muted',
};
return map[type];
};
export type MessageProps = PropsWithChildren<{
className?: string;
loading?: boolean;
fullWidth?: boolean;
type?: MessageType;
}>;
export const Message: FC<MessageProps> = (
{ className, children, loading = false, type = 'default', fullWidth = false },
) => {
const classes = classNames({
'col-md-12': fullWidth,
'col-md-10 offset-md-1': !fullWidth,
});
return (
<Row className={classNames('g-0', className)}>
<div className={classes}>
<Card className={getClassForType(type)} body>
<h3 className={classNames('text-center mb-0', getTextClassForType(type))}>
{loading && <FontAwesomeIcon icon={preloader} spin />}
{loading && <span className="ms-2">{children ?? 'Loading...'}</span>}
{!loading && children}
</h3>
</Card>
</div>
</Row>
);
};

View File

@@ -0,0 +1,31 @@
import classNames from 'classnames';
import type { FC, PropsWithChildren } from 'react';
import { Row } from 'reactstrap';
import { SimpleCard } from './SimpleCard';
export type ResultType = 'success' | 'error' | 'warning';
export type ResultProps = PropsWithChildren<{
type: ResultType;
className?: string;
small?: boolean;
}>;
export const Result: FC<ResultProps> = ({ children, type, className, small = false }) => (
<Row className={className}>
<div className={classNames({ 'col-md-10 offset-md-1': !small, 'col-12': small })}>
<SimpleCard
role="document"
className={classNames('text-center', {
'bg-main': type === 'success',
'bg-danger': type === 'error',
'bg-warning': type === 'warning',
'text-white': type !== 'warning',
})}
bodyClassName={classNames({ 'p-2': small })}
>
{children}
</SimpleCard>
</div>
</Row>
);

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import type { CardProps } from 'reactstrap';
import { Card, CardBody, CardHeader } from 'reactstrap';
interface SimpleCardProps extends Omit<CardProps, 'title'> {
title?: ReactNode;
bodyClassName?: string;
}
export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => (
<Card {...rest}>
{title && <CardHeader role="heading">{title}</CardHeader>}
<CardBody className={bodyClassName}>{children}</CardBody>
</Card>
);

View File

@@ -0,0 +1,3 @@
export * from './Message';
export * from './Result';
export * from './SimpleCard';

View File

@@ -0,0 +1,34 @@
import classNames from 'classnames';
import { identity } from 'ramda';
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { useDomId } from '../hooks';
export type BooleanControlProps = PropsWithChildren<{
checked?: boolean;
onChange?: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
className?: string;
inline?: boolean;
}>;
interface BooleanControlWithTypeProps extends BooleanControlProps {
type: 'switch' | 'checkbox';
}
export const BooleanControl: FC<BooleanControlWithTypeProps> = (
{ checked = false, onChange = identity, className, children, type, inline = false },
) => {
const id = useDomId();
const onChecked = (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked, e);
const typeClasses = {
'form-switch': type === 'switch',
'form-checkbox': type === 'checkbox',
};
const style = inline ? { display: 'inline-block' } : {};
return (
<span className={classNames('form-check', typeClasses, className)} style={style}>
<input type="checkbox" className="form-check-input" id={id} checked={checked} onChange={onChecked} />
<label className="form-check-label" htmlFor={id}>{children}</label>
</span>
);
};

View File

@@ -0,0 +1,5 @@
import type { FC } from 'react';
import type { BooleanControlProps } from './BooleanControl';
import { BooleanControl } from './BooleanControl';
export const Checkbox: FC<BooleanControlProps> = (props) => <BooleanControl type="checkbox" {...props} />;

View File

@@ -0,0 +1,34 @@
import type { FC, PropsWithChildren } from 'react';
import type { InputType } from 'reactstrap/types/lib/Input';
import { useDomId } from '../hooks';
import { LabeledFormGroup } from './LabeledFormGroup';
export type InputFormGroupProps = PropsWithChildren<{
value: string;
onChange: (newValue: string) => void;
type?: InputType;
required?: boolean;
placeholder?: string;
className?: string;
labelClassName?: string;
}>;
export const InputFormGroup: FC<InputFormGroupProps> = (
{ children, value, onChange, type, required, placeholder, className, labelClassName },
) => {
const id = useDomId();
return (
<LabeledFormGroup label={<>{children}:</>} className={className ?? ''} labelClassName={labelClassName} id={id}>
<input
id={id}
className="form-control"
type={type ?? 'text'}
value={value}
required={required ?? true}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</LabeledFormGroup>
);
};

View File

@@ -0,0 +1,19 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
type LabeledFormGroupProps = PropsWithChildren<{
label: ReactNode;
noMargin?: boolean;
className?: string;
labelClassName?: string;
id?: string;
}>;
/* eslint-disable jsx-a11y/label-has-associated-control */
export const LabeledFormGroup: FC<LabeledFormGroupProps> = (
{ children, label, className = '', labelClassName = '', noMargin = false, id },
) => (
<div className={`${className} ${noMargin ? '' : 'mb-3'}`}>
<label className={`form-label ${labelClassName}`} htmlFor={id}>{label}</label>
{children}
</div>
);

View File

@@ -0,0 +1,33 @@
@import '../../../src/utils/mixins/vertical-align';
.search-field {
position: relative;
&:focus-within {
z-index: 1;
}
}
.search-field__input.search-field__input {
padding-left: 40px;
padding-right: 40px;
}
.search-field__input--no-border.search-field__input--no-border {
border: none;
border-radius: 0;
}
.search-field__icon {
@include vertical-align();
left: 15px;
color: #6c757d;
}
.search-field__close {
@include vertical-align();
right: 10px;
cursor: pointer;
}

View File

@@ -0,0 +1,57 @@
import { faSearch as searchIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { useState } from 'react';
import './SearchField.scss';
const DEFAULT_SEARCH_INTERVAL = 500;
let timer: NodeJS.Timeout | null;
interface SearchFieldProps {
onChange: (value: string) => void;
className?: string;
large?: boolean;
noBorder?: boolean;
initialValue?: string;
}
export const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
const [searchTerm, setSearchTerm] = useState(initialValue);
const resetTimer = () => {
timer && clearTimeout(timer);
timer = null;
};
const searchTermChanged = (newSearchTerm: string, timeout = DEFAULT_SEARCH_INTERVAL) => {
setSearchTerm(newSearchTerm);
resetTimer();
timer = setTimeout(() => {
onChange(newSearchTerm);
resetTimer();
}, timeout);
};
return (
<div className={classNames('search-field', className)}>
<input
type="text"
className={classNames('form-control search-field__input', {
'form-control-lg': large,
'search-field__input--no-border': noBorder,
})}
placeholder="Search..."
value={searchTerm}
onChange={(e) => searchTermChanged(e.target.value)}
/>
<FontAwesomeIcon icon={searchIcon} className="search-field__icon" />
<div
className="close search-field__close btn-close"
hidden={searchTerm === ''}
id="search-field__close"
onClick={() => searchTermChanged('', 0)}
/>
</div>
);
};

View File

@@ -0,0 +1,5 @@
import type { FC } from 'react';
import type { BooleanControlProps } from './BooleanControl';
import { BooleanControl } from './BooleanControl';
export const ToggleSwitch: FC<BooleanControlProps> = (props) => <BooleanControl type="switch" {...props} />;

View File

@@ -0,0 +1,5 @@
export * from './Checkbox';
export * from './ToggleSwitch';
export * from './InputFormGroup';
export * from './LabeledFormGroup';
export * from './SearchField';

View File

@@ -0,0 +1,16 @@
import { useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
type ToggleResult = [boolean, () => void, () => void, () => void];
export const useToggle = (initialValue = false): ToggleResult => {
const [flag, setFlag] = useState<boolean>(initialValue);
return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)];
};
export const useDomId = (): string => {
const { current: id } = useRef(`dom-${uuid()}`);
return id;
};
export const useElementRef = <T>() => useRef<T | null>(null);

View File

@@ -0,0 +1,5 @@
export * from './block';
export * from './form';
export * from './hooks';
export * from './navigation';
export * from './ordering';

View File

@@ -0,0 +1,42 @@
/* stylelint-disable no-descending-specificity */
@import '../../../src/utils/mixins/vertical-align';
.dropdown-btn__toggle.dropdown-btn__toggle {
text-align: left;
}
.dropdown-btn__toggle.dropdown-btn__toggle--with-caret {
padding-right: 1.75rem;
}
.dropdown-btn__toggle.dropdown-btn__toggle,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
.show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
color: var(--input-text-color);
background-color: var(--primary-color);
border-color: var(--input-border-color);
}
.card .dropdown-btn__toggle.dropdown-btn__toggle,
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active,
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active,
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus,
.card .dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
.show > .card .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
background-color: var(--input-color);
}
.dropdown-btn__toggle.dropdown-btn__toggle.disabled,
.dropdown-btn__toggle.dropdown-btn__toggle:disabled {
background-color: var(--input-disabled-color);
}
.dropdown-btn__toggle.dropdown-btn__toggle:after {
@include vertical-align();
right: .75rem;
}

View File

@@ -0,0 +1,45 @@
import classNames from 'classnames';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
import { useToggle } from '../hooks';
import './DropdownBtn.scss';
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
text: ReactNode;
noCaret?: boolean;
className?: string;
dropdownClassName?: string;
inline?: boolean;
minWidth?: number;
size?: 'sm' | 'md' | 'lg';
}>;
export const DropdownBtn: FC<DropdownBtnProps> = ({
text,
disabled = false,
className,
children,
dropdownClassName,
noCaret,
end = false,
minWidth,
inline,
size,
}) => {
const [isOpen, toggle] = useToggle();
const toggleClasses = classNames('dropdown-btn__toggle', className, {
'btn-block': !inline,
'dropdown-btn__toggle--with-caret': !noCaret,
});
const menuStyle = { minWidth: minWidth && `${minWidth}px` };
return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle size={size} caret={!noCaret} className={toggleClasses} color="primary">
{text}
</DropdownToggle>
<DropdownMenu className="w-100" end={end} style={menuStyle}>{children}</DropdownMenu>
</Dropdown>
);
};

View File

@@ -0,0 +1,31 @@
@import '../../../src/utils/base';
.nav-pills__nav {
position: sticky !important;
top: $headerHeight - 1px;
z-index: 2;
}
.nav-pills__nav-link.nav-pills__nav-link {
border-radius: 0 !important;
padding-bottom: calc(.5rem - 3px) !important;
border-bottom: 3px solid transparent !important;
color: #5d6778;
font-weight: 700;
cursor: pointer;
text-decoration: none;
@media (min-width: $smMin) and (max-width: $mdMax) {
font-size: 89%;
}
}
.nav-pills__nav-link:hover {
color: $mainColor !important;
}
.nav-pills__nav-link.active {
border-color: $mainColor !important;
background-color: var(--primary-color) !important;
color: $mainColor !important;
}

View File

@@ -0,0 +1,35 @@
import type { FC, PropsWithChildren } from 'react';
import { Children, isValidElement } from 'react';
import { NavLink as RouterNavLink } from 'react-router-dom';
import { Card, Nav, NavLink } from 'reactstrap';
import './NavPills.scss';
type NavPillsProps = PropsWithChildren<{
fill?: boolean;
className?: string;
}>;
type NavPillProps = PropsWithChildren<{
to: string;
replace?: boolean;
}>;
export const NavPillItem: FC<NavPillProps> = ({ children, ...rest }) => (
<NavLink className="nav-pills__nav-link" tag={RouterNavLink} {...rest}>
{children}
</NavLink>
);
export const NavPills: FC<NavPillsProps> = ({ children, fill = false, className = '' }) => (
<Card className={`nav-pills__nav p-0 overflow-hidden ${className}`} body>
<Nav pills fill={fill}>
{Children.map(children, (child) => {
if (!isValidElement(child) || child.type !== NavPillItem) {
throw new Error('Only NavPillItem children are allowed inside NavPills.');
}
return child;
})}
</Nav>
</Card>
);

View File

@@ -0,0 +1,21 @@
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren } from 'react';
import { DropdownBtn } from './DropdownBtn';
export type DropdownBtnMenuProps = PropsWithChildren<{
minWidth?: number;
}>;
export const RowDropdownBtn: FC<DropdownBtnMenuProps> = ({ children, minWidth }) => (
<DropdownBtn
text={<FontAwesomeIcon className="px-1" icon={menuIcon} />}
size="sm"
minWidth={minWidth}
end
noCaret
inline
>
{children}
</DropdownBtn>
);

View File

@@ -0,0 +1,3 @@
export * from './DropdownBtn';
export * from './RowDropdownBtn';
export * from './NavPills';

View File

@@ -0,0 +1,8 @@
.ordering-dropdown__menu--link.ordering-dropdown__menu--link {
min-width: 11rem;
}
.ordering-dropdown__sort-icon {
margin: 3.5px 0 0;
float: right;
}

View File

@@ -0,0 +1,63 @@
import { faSortAmountDown as sortDescIcon, faSortAmountUp as sortAscIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { toPairs } from 'ramda';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { Order, OrderDir } from './ordering';
import { determineOrderDir } from './ordering';
import './OrderingDropdown.scss';
export interface OrderingDropdownProps<T extends string = string> {
items: Record<T, string>;
order: Order<T>;
onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean;
right?: boolean;
prefixed?: boolean;
}
export function OrderingDropdown<T extends string = string>(
{ items, order, onChange, isButton = true, right = false, prefixed = true }: OrderingDropdownProps<T>,
) {
const handleItemClick = (fieldKey: T) => () => {
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
};
return (
<UncontrolledDropdown>
<DropdownToggle
caret
color={isButton ? 'primary' : 'link'}
className={classNames({
'dropdown-btn__toggle btn-block pe-4 overflow-hidden': isButton,
'btn-sm p-0': !isButton,
})}
>
{!isButton && <>Order by</>}
{isButton && !order.field && <i>Order by...</i>}
{isButton && order.field && <>{prefixed && 'Order by: '}{items[order.field]} - <small>{order.dir ?? 'DESC'}</small></>}
</DropdownToggle>
<DropdownMenu
end={right}
className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })}
>
{toPairs(items).map(([fieldKey, fieldValue]) => (
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
{fieldValue}
{order.field === fieldKey && (
<FontAwesomeIcon
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
className="ordering-dropdown__sort-icon"
/>
)}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem disabled={!order.field} onClick={() => onChange()}>
<i>Clear selection</i>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ordering';
export * from './OrderingDropdown';

View File

@@ -0,0 +1,41 @@
export type OrderDir = 'ASC' | 'DESC' | undefined;
export interface Order<Fields> {
field?: Fields;
dir?: OrderDir;
}
export const determineOrderDir = <T extends string = string>(
currentField: T,
newField?: T,
currentOrderDir?: OrderDir,
): OrderDir => {
if (currentField !== newField) {
return 'ASC';
}
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
ASC: 'DESC',
DESC: undefined,
};
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
};
export const sortList = <List>(list: List[], { field, dir }: Order<keyof List>) => (
!field || !dir ? list : list.sort((a, b) => {
const greaterThan = dir === 'ASC' ? 1 : -1;
const smallerThan = dir === 'ASC' ? -1 : 1;
return a[field] > b[field] ? greaterThan : smallerThan;
})
);
export const orderToString = <T>(order: Order<T>): string | undefined => (
order.dir ? `${order.field}-${order.dir}` : undefined
);
export const stringToOrder = <T>(order: string): Order<T> => {
const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined];
return { field, dir };
};