This commit is contained in:
23
src/colors.ts
Normal file
23
src/colors.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const COLORS = [
|
||||
'red',
|
||||
'pink',
|
||||
'purple',
|
||||
'deepPurple',
|
||||
'indigo',
|
||||
'blue',
|
||||
'lightBlue',
|
||||
'cyan',
|
||||
'teal',
|
||||
'green',
|
||||
'lightGreen',
|
||||
'lime',
|
||||
'yellow',
|
||||
'amber',
|
||||
'orange',
|
||||
'deepOrange',
|
||||
'brown',
|
||||
'grey',
|
||||
'blueGrey',
|
||||
] as const;
|
||||
|
||||
export type SSColor = (typeof COLORS)[number];
|
||||
38
src/components/SSAttrList.tsx
Normal file
38
src/components/SSAttrList.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { JSX, JSXElement, Show, children } from 'solid-js';
|
||||
|
||||
type SSAttrListProps = {
|
||||
children?: JSXElement;
|
||||
} & Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children'>;
|
||||
|
||||
type SSAttrListAttrProps = {
|
||||
name: JSXElement;
|
||||
value: JSXElement;
|
||||
third?: JSXElement;
|
||||
};
|
||||
|
||||
type SSAttrListComponent = ((props: SSAttrListProps) => JSXElement | null) & {
|
||||
Attr: (props: SSAttrListAttrProps) => JSXElement;
|
||||
};
|
||||
|
||||
const SSAttrList: SSAttrListComponent = (props: SSAttrListProps) => {
|
||||
const resolved = children(() => props.children);
|
||||
const { class: className, children: _children, ...rest } = props;
|
||||
|
||||
return <Show when={!!resolved()}>
|
||||
<div {...rest} class={`ss_attr_list ${className ?? ''}`}>
|
||||
{resolved()}
|
||||
</div>
|
||||
</Show>;
|
||||
};
|
||||
|
||||
SSAttrList.Attr = function (props: SSAttrListAttrProps) {
|
||||
return (
|
||||
<div class={`ss_attr_list__row ${props.third ? 'ss_attr_list__row--third' : ''}`}>
|
||||
<div class="ss_attr_list__label">{props.name}</div>
|
||||
<div class="ss_attr_list__value">{props.value}</div>
|
||||
{!!props.third && <div class="ss_attr_list__value">{props.third}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { SSAttrList };
|
||||
35
src/components/SSButton.tsx
Normal file
35
src/components/SSButton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { JSX } from 'solid-js';
|
||||
|
||||
type SSButtonProps = {
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
isIconOnly?: boolean;
|
||||
onclick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
|
||||
ariaLabel?: string;
|
||||
form?: string;
|
||||
};
|
||||
|
||||
function SSButton(props: SSButtonProps) {
|
||||
const classes = () => [
|
||||
'ss_button',
|
||||
props.isIconOnly ? 'ss_button--icon' : '',
|
||||
props.class ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type={props.type ?? 'button'}
|
||||
class={classes()}
|
||||
disabled={props.disabled}
|
||||
aria-label={props.ariaLabel}
|
||||
form={props.form}
|
||||
onclick={props.onclick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSButton };
|
||||
28
src/components/SSCallout.tsx
Normal file
28
src/components/SSCallout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { JSX, JSXElement } from 'solid-js';
|
||||
import { COLORS } from 'src/colors';
|
||||
|
||||
type SSCalloutProps = JSX.HTMLAttributes<HTMLDivElement> & {
|
||||
color: (typeof COLORS)[number];
|
||||
icon: JSXElement;
|
||||
};
|
||||
|
||||
function SSCallout(props: SSCalloutProps) {
|
||||
const { icon, color, class: className, style, children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
class={`ss_callout ss_callout--${color} ${className ?? ''}`}
|
||||
style={style}
|
||||
>
|
||||
<span class="ss_callout__icon">
|
||||
{icon}
|
||||
</span>
|
||||
<div class="ss_callout__content">
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSCallout };
|
||||
62
src/components/SSChip.tsx
Normal file
62
src/components/SSChip.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { JSX, JSXElement } from 'solid-js';
|
||||
import { COLORS } from 'src/colors';
|
||||
|
||||
type SSChipBaseProps = {
|
||||
children: JSXElement;
|
||||
color?: (typeof COLORS)[number];
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type SSChipClickableProps = {
|
||||
onclick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
|
||||
ondismiss?: never;
|
||||
};
|
||||
|
||||
type SSChipDismissableProps = {
|
||||
ondismiss: () => void;
|
||||
onclick?: never;
|
||||
};
|
||||
|
||||
type SSChipDisplayProps = {
|
||||
onclick?: never;
|
||||
ondismiss?: never;
|
||||
};
|
||||
|
||||
type SSChipProps = SSChipBaseProps &
|
||||
(SSChipClickableProps | SSChipDismissableProps | SSChipDisplayProps);
|
||||
|
||||
function SSChip(props: SSChipProps) {
|
||||
const commonClass = `ss_chip ss_chip--${props.color ?? 'blue'} ${props.class ?? ''}`;
|
||||
|
||||
if ('onclick' in props && props.onclick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`${commonClass} ss_chip--clickable`}
|
||||
style={props.style}
|
||||
onclick={props.onclick}
|
||||
>
|
||||
<span class="ss_chip__label">{props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={commonClass} style={props.style}>
|
||||
<span class="ss_chip__label">{props.children}</span>
|
||||
{'ondismiss' in props && props.ondismiss && (
|
||||
<button
|
||||
type="button"
|
||||
class="ss_chip__dismiss"
|
||||
aria-label="Entfernen"
|
||||
onclick={props.ondismiss}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSChip };
|
||||
245
src/components/SSDataTable.tsx
Normal file
245
src/components/SSDataTable.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { JSXElement, createEffect, createMemo, createResource, createSignal, For } from 'solid-js';
|
||||
|
||||
type PromiseOr<T> = Promise<T> | T;
|
||||
|
||||
type SSDataColumn<RowT> = {
|
||||
label: string;
|
||||
render: (row: RowT) => PromiseOr<JSXElement>;
|
||||
sortKey?: ((row: RowT) => string) | ((row: RowT) => number);
|
||||
};
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
type SSDataTableProps<RowT extends object> = {
|
||||
columns: SSDataColumn<RowT>[];
|
||||
rows: RowT[];
|
||||
onRowClick?: (row: RowT) => PromiseOr<void>;
|
||||
pageSize?: number;
|
||||
paginationPosition?: 'top' | 'bottom';
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSDataCell<RowT extends object>(props: {
|
||||
row: RowT;
|
||||
render: (row: RowT) => PromiseOr<JSXElement>;
|
||||
}) {
|
||||
const rendered = createMemo(() => props.render(props.row));
|
||||
const [asyncContent] = createResource(async () => {
|
||||
const value = rendered();
|
||||
if (value && typeof (value as Promise<JSXElement>).then === 'function') {
|
||||
return await value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(() => {
|
||||
const value = rendered();
|
||||
if (value && typeof (value as Promise<JSXElement>).then === 'function') {
|
||||
return asyncContent() ?? '';
|
||||
}
|
||||
return value as JSXElement;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SSDataTable<RowT extends object>(props: SSDataTableProps<RowT>) {
|
||||
const [sortIndex, setSortIndex] = createSignal(-1);
|
||||
const [sortDir, setSortDir] = createSignal<SortDir | null>(null);
|
||||
const [page, setPage] = createSignal(1);
|
||||
|
||||
const pageSize = () => Math.max(1, props.pageSize ?? 25);
|
||||
|
||||
const sortedRows = createMemo(() => {
|
||||
const index = sortIndex();
|
||||
const dir = sortDir();
|
||||
if (index < 0 || !dir) return props.rows;
|
||||
|
||||
const column = props.columns[index];
|
||||
if (!column?.sortKey) return props.rows;
|
||||
|
||||
const entries = props.rows.map((row, idx) => ({
|
||||
row,
|
||||
idx,
|
||||
key: column.sortKey!(row),
|
||||
}));
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.key === b.key) return a.idx - b.idx;
|
||||
if (a.key < b.key) return dir === 'asc' ? -1 : 1;
|
||||
return dir === 'asc' ? 1 : -1;
|
||||
});
|
||||
|
||||
return entries.map((entry) => entry.row);
|
||||
});
|
||||
|
||||
const totalPages = createMemo(() => {
|
||||
return Math.max(1, Math.ceil(sortedRows().length / pageSize()));
|
||||
});
|
||||
|
||||
const pagedRows = createMemo(() => {
|
||||
const current = Math.min(page(), totalPages());
|
||||
const start = (current - 1) * pageSize();
|
||||
return sortedRows().slice(start, start + pageSize());
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (page() > totalPages()) setPage(totalPages());
|
||||
});
|
||||
|
||||
const toggleSort = (index: number) => {
|
||||
if (sortIndex() !== index) {
|
||||
setSortIndex(index);
|
||||
setSortDir('asc');
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortDir() === 'asc') {
|
||||
setSortDir('desc');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortDir() === 'desc') {
|
||||
setSortIndex(-1);
|
||||
setSortDir(null);
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
setSortDir('asc');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const goToPage = (next: number) => {
|
||||
const safePage = Math.min(Math.max(1, next), totalPages());
|
||||
setPage(safePage);
|
||||
};
|
||||
|
||||
const paginationPosition = () => props.paginationPosition ?? 'bottom';
|
||||
const containerClass = () =>
|
||||
`ss_table ${paginationPosition() === 'top' ? 'ss_table--pagination-top' : ''} ${
|
||||
props.class ?? ''
|
||||
}`;
|
||||
|
||||
const paginationBar = () => (
|
||||
<div class="ss_table__pagination">
|
||||
<button
|
||||
type="button"
|
||||
class="ss_table__page_button"
|
||||
disabled={page() === 1}
|
||||
aria-label="Erste Seite"
|
||||
onclick={() => goToPage(1)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left-pipe"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 6v12" /><path d="M18 6l-6 6l6 6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ss_table__page_button"
|
||||
disabled={page() === 1}
|
||||
aria-label="Vorherige Seite"
|
||||
onclick={() => goToPage(page() - 1)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 6l-6 6l6 6" /></svg>
|
||||
</button>
|
||||
<span class="ss_table__page_info">
|
||||
Seite {page()} von {totalPages()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ss_table__page_button"
|
||||
disabled={page() === totalPages()}
|
||||
aria-label="Nächste Seite"
|
||||
onclick={() => goToPage(page() + 1)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ss_table__page_button"
|
||||
disabled={page() === totalPages()}
|
||||
aria-label="Letzte Seite"
|
||||
onclick={() => goToPage(totalPages())}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right-pipe"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 6l6 6l-6 6" /><path d="M17 5v13" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={containerClass()} style={props.style}>
|
||||
{paginationPosition() === 'top' && paginationBar()}
|
||||
<div class="ss_table__scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<For each={props.columns}>
|
||||
{(column, index) => {
|
||||
const sortable = !!column.sortKey;
|
||||
const isActive = () => sortIndex() === index();
|
||||
const currentDir = () => (isActive() ? sortDir() : null);
|
||||
return (
|
||||
<th>
|
||||
{sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
class="ss_table__sort_button"
|
||||
aria-sort={
|
||||
currentDir() === 'asc'
|
||||
? 'ascending'
|
||||
: currentDir() === 'desc'
|
||||
? 'descending'
|
||||
: 'none'
|
||||
}
|
||||
data-sort={currentDir() ?? 'none'}
|
||||
onclick={() => toggleSort(index())}
|
||||
>
|
||||
{column.label}
|
||||
<span class="ss_table__sort_icon" aria-hidden="true">
|
||||
{currentDir() === 'asc' ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6l6 -6" /></svg>
|
||||
) : currentDir() === 'desc' ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-up"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 15l6 -6l6 6" /></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-selector"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9l4 -4l4 4" /><path d="M16 15l-4 4l-4 -4" /></svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span class="ss_table__header_label">{column.label}</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={pagedRows()}>
|
||||
{(row) => (
|
||||
<tr
|
||||
data-clickable={props.onRowClick ? 'true' : undefined}
|
||||
onclick={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<For each={props.columns}>
|
||||
{(column) => (
|
||||
<td>
|
||||
<SSDataCell row={row} render={column.render} />
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{paginationPosition() === 'bottom' && paginationBar()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSDataTable };
|
||||
18
src/components/SSDivider.tsx
Normal file
18
src/components/SSDivider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
type SSDividerProps = {
|
||||
vertical?: boolean;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSDivider(props: SSDividerProps) {
|
||||
return (
|
||||
<div
|
||||
class={`ss_divider ${props.vertical ? 'ss_divider--vertical' : ''} ${props.class ?? ''}`}
|
||||
style={props.style}
|
||||
role="separator"
|
||||
aria-orientation={props.vertical ? 'vertical' : 'horizontal'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSDivider };
|
||||
109
src/components/SSDropdown.tsx
Normal file
109
src/components/SSDropdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { JSXElement, createSignal, For, onCleanup, onMount, Show, createEffect } from 'solid-js';
|
||||
|
||||
type PromiseOr<T> = Promise<T> | T;
|
||||
|
||||
type SSDropdownItem = {
|
||||
label: string;
|
||||
icon: JSXElement;
|
||||
onclick?: () => PromiseOr<void>;
|
||||
checked?: boolean;
|
||||
};
|
||||
|
||||
type SSDropdownProps = {
|
||||
items: SSDropdownItem[];
|
||||
icon?: JSXElement;
|
||||
ariaLabel?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSDropdown(props: SSDropdownProps) {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [renderMenu, setRenderMenu] = createSignal(false);
|
||||
const [menuState, setMenuState] = createSignal<'open' | 'closed'>('closed');
|
||||
let rootRef: HTMLDivElement | undefined;
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (open()) {
|
||||
setRenderMenu(true);
|
||||
requestAnimationFrame(() => setMenuState('open'));
|
||||
return;
|
||||
}
|
||||
if (!renderMenu()) return;
|
||||
setMenuState('closed');
|
||||
const timeout = window.setTimeout(() => setRenderMenu(false), 160);
|
||||
onCleanup(() => window.clearTimeout(timeout));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!rootRef || !target) return;
|
||||
if (!rootRef.contains(target)) close();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={`ss_dropdown ${props.class ?? ''}`} style={props.style} ref={el => (rootRef = el)}>
|
||||
<button
|
||||
type="button"
|
||||
class="ss_dropdown__trigger ss_button ss_button--icon"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open()}
|
||||
aria-label={props.ariaLabel ?? 'Aktionen öffnen'}
|
||||
onclick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{props.icon ?? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-dots-vertical"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Show when={renderMenu()}>
|
||||
<div
|
||||
class="ss_dropdown__menu"
|
||||
role="menu"
|
||||
data-state={menuState()}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="ss_dropdown__item"
|
||||
role={item.checked ? 'menuitemcheckbox' : 'menuitem'}
|
||||
aria-checked={item.checked ? 'true' : undefined}
|
||||
onclick={async () => {
|
||||
close();
|
||||
await item.onclick?.();
|
||||
}}
|
||||
>
|
||||
<span class="ss_dropdown__item_icon">{item.icon}</span>
|
||||
<span class="ss_dropdown__item_label">{item.label}</span>
|
||||
{item.checked && (
|
||||
<span class="ss_dropdown__item_check" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSDropdown };
|
||||
83
src/components/SSExpandable.tsx
Normal file
83
src/components/SSExpandable.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { JSXElement, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
|
||||
type SSExpandableProps = {
|
||||
title: JSXElement;
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
style?: string;
|
||||
initiallyExpanded?: boolean;
|
||||
};
|
||||
|
||||
const TRANSITION_MS = 200;
|
||||
|
||||
function SSExpandable(props: SSExpandableProps) {
|
||||
const [height, setHeight] = createSignal<string | number>(props.initiallyExpanded ? 'auto' : 0);
|
||||
const isExpanded = createMemo(() => height() !== 0);
|
||||
let contentRef: HTMLDivElement | undefined;
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const toggle = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
const targetHeight = contentRef?.scrollHeight ?? 0;
|
||||
|
||||
if (isExpanded()) {
|
||||
setHeight(targetHeight);
|
||||
timeoutId = window.setTimeout(() => setHeight(0), 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setHeight(targetHeight);
|
||||
timeoutId = window.setTimeout(() => setHeight('auto'), TRANSITION_MS);
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`ss_expandable ${props.class ?? ''}`}
|
||||
style={props.style}
|
||||
data-state={isExpanded() ? 'open' : 'closed'}
|
||||
>
|
||||
<div
|
||||
class="ss_expandable__header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded={isExpanded()}
|
||||
onclick={toggle}
|
||||
onkeydown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="ss_expandable__icon" aria-hidden="true">
|
||||
{isExpanded() ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6l6 -6" /></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span class="ss_expandable__title">{props.title}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={el => (contentRef = el)}
|
||||
class="ss_expandable__content"
|
||||
style={{
|
||||
height: (typeof height() === 'number' ? `${height()}px` : height()) as any,
|
||||
'transition-duration': `${TRANSITION_MS}ms`,
|
||||
}}
|
||||
>
|
||||
<div class='ss_expandable__divider_wrapper'>
|
||||
<div class="ss_expandable__divider" />
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSExpandable };
|
||||
1062
src/components/SSForm.tsx
Normal file
1062
src/components/SSForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
src/components/SSHeader.tsx
Normal file
23
src/components/SSHeader.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { JSXElement } from 'solid-js';
|
||||
|
||||
type SSHeaderProps = {
|
||||
title: JSXElement;
|
||||
subtitle?: JSXElement | false;
|
||||
actions?: JSXElement | false;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSHeader(props: SSHeaderProps) {
|
||||
return (
|
||||
<div class={`ss_header ${props.class ?? ''}`} style={props.style}>
|
||||
<div class="ss_header__text">
|
||||
<h3 class="ss_header__title">{props.title}</h3>
|
||||
{props.subtitle && <h5 class="ss_header__subtitle">{props.subtitle}</h5>}
|
||||
</div>
|
||||
<div class="ss_header__actions">{props.actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSHeader };
|
||||
135
src/components/SSModal.tsx
Normal file
135
src/components/SSModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { JSXElement, Show, createEffect, createSignal, createUniqueId, onCleanup, onMount } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { SSButton } from './SSButton';
|
||||
|
||||
type SSModalSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export type SSModalProps = {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
children: JSXElement;
|
||||
footer?: JSXElement;
|
||||
size?: SSModalSize;
|
||||
fullscreen?: boolean;
|
||||
disableResponsiveFullscreen?: boolean;
|
||||
dismissible?: boolean;
|
||||
lockScroll?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const CLOSE_ANIMATION_MS = 180;
|
||||
|
||||
function SSModal(props: SSModalProps) {
|
||||
const [isMounted, setIsMounted] = createSignal(props.open);
|
||||
const [state, setState] = createSignal<'open' | 'closed'>('closed');
|
||||
const titleId = createUniqueId();
|
||||
let closeTimeout: number | undefined;
|
||||
let rafId: number | undefined;
|
||||
let panelRef: HTMLDivElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (closeTimeout) clearTimeout(closeTimeout);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
|
||||
if (props.open) {
|
||||
if (!isMounted()) setIsMounted(true);
|
||||
setState('closed');
|
||||
rafId = requestAnimationFrame(() => setState('open'));
|
||||
} else {
|
||||
setState('closed');
|
||||
if (isMounted()) {
|
||||
closeTimeout = window.setTimeout(() => setIsMounted(false), CLOSE_ANIMATION_MS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!props.open) return;
|
||||
|
||||
if (props.lockScroll !== false) {
|
||||
const prev = document.body.style.overflowY;
|
||||
document.body.style.overflowY = 'hidden';
|
||||
onCleanup(() => {
|
||||
document.body.style.overflowY = prev;
|
||||
});
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(() => panelRef?.focus());
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.dismissible !== false) {
|
||||
props.onClose?.();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => window.removeEventListener('keydown', handleKeyDown));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (closeTimeout) clearTimeout(closeTimeout);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (props.dismissible === false) return;
|
||||
props.onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={isMounted()}>
|
||||
<Portal>
|
||||
<div class="ss_modal" data-state={state()} aria-hidden={state() === 'closed'}>
|
||||
<div class="ss_modal__backdrop" onclick={handleBackdropClick} />
|
||||
<div
|
||||
class="ss_modal__panel"
|
||||
classList={{
|
||||
'ss_modal__panel--sm': props.size === 'sm',
|
||||
'ss_modal__panel--lg': props.size === 'lg',
|
||||
'ss_modal__panel--fullscreen': props.fullscreen,
|
||||
'ss_modal__panel--no-fullscreen': props.disableResponsiveFullscreen,
|
||||
}}
|
||||
ref={el => (panelRef = el)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={props.title ? titleId : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
{(props.title || props.onClose) && (
|
||||
<div class="ss_modal__header">
|
||||
{props.title && (
|
||||
<h2 id={titleId} class="ss_modal__title">
|
||||
{props.title}
|
||||
</h2>
|
||||
)}
|
||||
<Show when={props.onClose}>
|
||||
<SSButton
|
||||
type="button"
|
||||
class="ss_modal__close"
|
||||
isIconOnly
|
||||
ariaLabel="Dialog schließen"
|
||||
onclick={() => {
|
||||
if (props.dismissible === false) return;
|
||||
props.onClose?.();
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
|
||||
</SSButton>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="ss_modal__body">
|
||||
<div class="ss_modal__body_inner">{props.children}</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.footer}>
|
||||
<div class="ss_modal__footer">{props.footer}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSModal };
|
||||
263
src/components/SSModals.tsx
Normal file
263
src/components/SSModals.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { JSXElement, createContext, createSignal, For, onCleanup, useContext } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import { SSModal, type SSModalProps } from './SSModal';
|
||||
import { createLoading } from '../hooks/createLoading';
|
||||
import { SSForm } from './SSForm';
|
||||
import { SSButton } from './SSButton';
|
||||
|
||||
type PromiseOr<T> = Promise<T> | T;
|
||||
|
||||
type SSModalEntry = {
|
||||
id: string;
|
||||
visible: () => boolean;
|
||||
setVisible: (value: boolean) => void;
|
||||
render: (props: { id: string; hide: () => void; visible: () => boolean }) => JSXElement;
|
||||
};
|
||||
|
||||
type SSFormPublicContext = NonNullable<ReturnType<typeof SSForm.useContext>>;
|
||||
|
||||
type DefaultModalProps = {
|
||||
content: (props: { hide: () => void }) => JSXElement;
|
||||
modalProps?: (props: { hide: () => void }) => Omit<
|
||||
SSModalProps,
|
||||
'open' | 'onClose' | 'children' | 'footer'
|
||||
> & {
|
||||
primaryButtonText?: string;
|
||||
secondaryButtonText?: string;
|
||||
hideSecondaryButton?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
onPrimaryAction?: (props: {
|
||||
hide: () => void;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
pathname: string;
|
||||
}) => PromiseOr<void>;
|
||||
};
|
||||
|
||||
type FormModalProps = {
|
||||
content: (props: { hide: () => void; context: SSFormPublicContext }) => JSXElement;
|
||||
modalProps?: (props: {
|
||||
hide: () => void;
|
||||
context: SSFormPublicContext;
|
||||
}) => Omit<
|
||||
SSModalProps,
|
||||
'open' | 'onClose' | 'children' | 'footer'
|
||||
> & {
|
||||
primaryButtonText?: string;
|
||||
secondaryButtonText?: string;
|
||||
hideSecondaryButton?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
onSubmit: (props: {
|
||||
hide: () => void;
|
||||
context: SSFormPublicContext;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
pathname: string;
|
||||
}) => PromiseOr<void>;
|
||||
};
|
||||
|
||||
export interface SSModalsInterface {
|
||||
show: (render: SSModalEntry['render']) => string;
|
||||
showDefault: (props: DefaultModalProps) => string;
|
||||
showForm: (props: FormModalProps) => string;
|
||||
hide: (id: string) => void;
|
||||
}
|
||||
|
||||
const SSModalsContext = createContext<SSModalsInterface>();
|
||||
|
||||
let modalCounter = 0;
|
||||
const nextModalId = () => `ss-modal-${modalCounter++}`;
|
||||
|
||||
export function useSSModals() {
|
||||
const context = useContext(SSModalsContext);
|
||||
if (!context) {
|
||||
throw new Error('useSSModals must be used within SSModalsProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function DefaultModal(props: {
|
||||
visible: () => boolean;
|
||||
hide: () => void;
|
||||
config: DefaultModalProps;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loading, process] = createLoading();
|
||||
|
||||
const modalProps = () => props.config.modalProps?.({ hide: props.hide }) ?? {};
|
||||
const {
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
hideSecondaryButton,
|
||||
danger,
|
||||
...rest
|
||||
} = modalProps();
|
||||
|
||||
return (
|
||||
<SSModal
|
||||
open={props.visible()}
|
||||
onClose={props.hide}
|
||||
{...rest}
|
||||
footer={
|
||||
<>
|
||||
{!hideSecondaryButton && (
|
||||
<SSButton class="secondary" onclick={props.hide}>
|
||||
{secondaryButtonText ?? 'Abbrechen'}
|
||||
</SSButton>
|
||||
)}
|
||||
<SSButton
|
||||
class={danger ? 'danger' : undefined}
|
||||
onclick={() =>
|
||||
process(() =>
|
||||
props.config.onPrimaryAction?.({
|
||||
hide: props.hide,
|
||||
navigate,
|
||||
pathname: location.pathname,
|
||||
}) ?? props.hide(),
|
||||
)
|
||||
}
|
||||
disabled={loading()}
|
||||
>
|
||||
{primaryButtonText ?? 'Weiter'}
|
||||
</SSButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.config.content({ hide: props.hide })}
|
||||
</SSModal>
|
||||
);
|
||||
}
|
||||
|
||||
function FormModal(props: {
|
||||
visible: () => boolean;
|
||||
hide: () => void;
|
||||
config: FormModalProps;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<SSForm
|
||||
onsubmit={(context) =>
|
||||
props.config.onSubmit({
|
||||
hide: props.hide,
|
||||
context,
|
||||
navigate,
|
||||
pathname: location.pathname,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FormModalInner visible={props.visible} hide={props.hide} config={props.config} />
|
||||
</SSForm>
|
||||
);
|
||||
}
|
||||
|
||||
function FormModalInner(props: {
|
||||
visible: () => boolean;
|
||||
hide: () => void;
|
||||
config: FormModalProps;
|
||||
}) {
|
||||
const context = SSForm.useContext();
|
||||
if (!context) return null;
|
||||
|
||||
const modalProps = () => props.config.modalProps?.({ hide: props.hide, context }) ?? {};
|
||||
const {
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
hideSecondaryButton,
|
||||
danger,
|
||||
...rest
|
||||
} = modalProps();
|
||||
|
||||
return (
|
||||
<SSModal
|
||||
open={props.visible()}
|
||||
onClose={props.hide}
|
||||
{...rest}
|
||||
footer={
|
||||
<>
|
||||
{!hideSecondaryButton && (
|
||||
<SSButton class="secondary" onclick={props.hide} disabled={context.loading()}>
|
||||
{secondaryButtonText ?? 'Abbrechen'}
|
||||
</SSButton>
|
||||
)}
|
||||
<SSButton
|
||||
class={danger ? 'danger' : undefined}
|
||||
onclick={() => context.submit()}
|
||||
disabled={context.loading()}
|
||||
>
|
||||
{primaryButtonText ?? 'Speichern'}
|
||||
</SSButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.config.content({ hide: props.hide, context })}
|
||||
</SSModal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SSModalsProvider(props: { children: JSXElement }) {
|
||||
const [modals, setModals] = createSignal<SSModalEntry[]>([]);
|
||||
const modalsById = new Map<string, SSModalEntry>();
|
||||
const closeTimeouts = new Map<string, number>();
|
||||
const removeDelayMs = 220;
|
||||
|
||||
const hide = (id: string) => {
|
||||
const modal = modalsById.get(id);
|
||||
if (!modal) return;
|
||||
|
||||
modal.setVisible(false);
|
||||
|
||||
const existing = closeTimeouts.get(id);
|
||||
if (existing) window.clearTimeout(existing);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setModals((list) => list.filter((modal) => modal.id !== id));
|
||||
modalsById.delete(id);
|
||||
closeTimeouts.delete(id);
|
||||
}, removeDelayMs);
|
||||
|
||||
closeTimeouts.set(id, timeout);
|
||||
};
|
||||
|
||||
const show = (render: SSModalEntry['render']) => {
|
||||
const id = nextModalId();
|
||||
const [visible, setVisible] = createSignal(true);
|
||||
const entry = { id, visible, setVisible, render };
|
||||
modalsById.set(id, entry);
|
||||
setModals((list) => [...list, entry]);
|
||||
return id;
|
||||
};
|
||||
|
||||
const showDefault = (config: DefaultModalProps) => {
|
||||
return show(({ hide, visible }) => (
|
||||
<DefaultModal visible={visible} hide={hide} config={config} />
|
||||
));
|
||||
};
|
||||
|
||||
const showForm = (config: FormModalProps) => {
|
||||
return show(({ hide, visible }) => (
|
||||
<FormModal visible={visible} hide={hide} config={config} />
|
||||
));
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
closeTimeouts.forEach((timeout) => window.clearTimeout(timeout));
|
||||
closeTimeouts.clear();
|
||||
});
|
||||
|
||||
return (
|
||||
<SSModalsContext.Provider value={{ show, showDefault, showForm, hide }}>
|
||||
{props.children}
|
||||
|
||||
<For each={modals()}>
|
||||
{(modal) => {
|
||||
const hideModal = () => hide(modal.id);
|
||||
return modal.render({ id: modal.id, hide: hideModal, visible: modal.visible });
|
||||
}}
|
||||
</For>
|
||||
</SSModalsContext.Provider>
|
||||
);
|
||||
}
|
||||
159
src/components/SSShell.tsx
Normal file
159
src/components/SSShell.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { JSXElement, createContext, createMemo, createSignal, createUniqueId, onCleanup, onMount, useContext } from 'solid-js';
|
||||
import { useLocation } from '@solidjs/router';
|
||||
|
||||
type SSShellProps = {
|
||||
title: JSXElement;
|
||||
nav: JSXElement;
|
||||
children: JSXElement;
|
||||
actions?: JSXElement;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type SSShellContextValue = {
|
||||
closeDrawer: () => void;
|
||||
activeHref: () => string | null;
|
||||
registerHref: (href: string) => void;
|
||||
unregisterHref: (href: string) => void;
|
||||
};
|
||||
|
||||
const SSShellContext = createContext<SSShellContextValue>();
|
||||
|
||||
function SSShell(props: SSShellProps) {
|
||||
const drawerId = createUniqueId();
|
||||
const location = useLocation();
|
||||
const [hrefs, setHrefs] = createSignal<string[]>([]);
|
||||
|
||||
const closeDrawer = () => {
|
||||
const input = document.getElementById(drawerId) as HTMLInputElement | null;
|
||||
if (input) input.checked = false;
|
||||
};
|
||||
|
||||
const registerHref = (href: string) => {
|
||||
setHrefs((prev) => (prev.includes(href) ? prev : [...prev, href]));
|
||||
};
|
||||
|
||||
const unregisterHref = (href: string) => {
|
||||
setHrefs((prev) => prev.filter((item) => item !== href));
|
||||
};
|
||||
|
||||
const activeHref = createMemo(() => {
|
||||
const path = location.pathname;
|
||||
let best: string | null = null;
|
||||
for (const href of hrefs()) {
|
||||
if (!path.startsWith(href)) continue;
|
||||
if (!best || href.length > best.length) {
|
||||
best = href;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
});
|
||||
|
||||
return (
|
||||
<SSShellContext.Provider value={{ closeDrawer, activeHref, registerHref, unregisterHref }}>
|
||||
<div class={`ss_shell ${props.class ?? ''}`} style={props.style}>
|
||||
<input id={drawerId} type="checkbox" class="ss_shell__drawer_toggle_input" />
|
||||
|
||||
<header class="ss_shell__header">
|
||||
<div class="ss_shell__header_left">
|
||||
<label
|
||||
for={drawerId}
|
||||
class="ss_shell__drawer_toggle ss_button ss_button--icon"
|
||||
aria-label="Navigation öffnen"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-menu-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l16 0" /></svg>
|
||||
</label>
|
||||
<div class="ss_shell__title">{props.title}</div>
|
||||
</div>
|
||||
<div class="ss_shell__actions">{props.actions}</div>
|
||||
</header>
|
||||
|
||||
<div class="ss_shell__body">
|
||||
<nav class="ss_shell__nav" aria-label="Hauptnavigation">
|
||||
<div class="ss_shell__nav_inner">{props.nav}</div>
|
||||
</nav>
|
||||
<div class="ss_shell__main">{props.children}</div>
|
||||
<label for={drawerId} class="ss_shell__scrim" aria-label="Navigation schließen" />
|
||||
</div>
|
||||
</div>
|
||||
</SSShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
SSShell.Nav = function (props: { children: JSXElement }) {
|
||||
return <div class="ss_shell__nav_list">{props.children}</div>;
|
||||
};
|
||||
|
||||
SSShell.NavLink = function (props: {
|
||||
href: string;
|
||||
children: JSXElement;
|
||||
icon?: JSXElement;
|
||||
onclick?: () => void;
|
||||
}) {
|
||||
const context = useContext(SSShellContext);
|
||||
|
||||
onMount(() => context?.registerHref(props.href));
|
||||
onCleanup(() => context?.unregisterHref(props.href));
|
||||
|
||||
const isActive = () => context?.activeHref() === props.href;
|
||||
|
||||
return (
|
||||
<a
|
||||
class="ss_shell__nav_item"
|
||||
classList={{ 'ss_shell__nav_item--active': isActive() }}
|
||||
href={props.href}
|
||||
onclick={() => {
|
||||
props.onclick?.();
|
||||
context?.closeDrawer();
|
||||
}}
|
||||
>
|
||||
{props.icon && <span class="ss_shell__nav_icon">{props.icon}</span>}
|
||||
<span class="ss_shell__nav_label">{props.children}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
SSShell.NavAction = function (props: {
|
||||
onclick: () => void;
|
||||
children: JSXElement;
|
||||
icon?: JSXElement;
|
||||
}) {
|
||||
const context = useContext(SSShellContext);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="ss_shell__nav_item"
|
||||
onclick={() => {
|
||||
props.onclick();
|
||||
context?.closeDrawer();
|
||||
}}
|
||||
>
|
||||
{props.icon && <span class="ss_shell__nav_icon">{props.icon}</span>}
|
||||
<span class="ss_shell__nav_label">{props.children}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SSShell.NavGroup = function (props: {
|
||||
title: JSXElement;
|
||||
children: JSXElement;
|
||||
icon?: JSXElement;
|
||||
initiallyExpanded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<details class="ss_shell__nav_group" open={props.initiallyExpanded}>
|
||||
<summary class="ss_shell__nav_group_header">
|
||||
{props.icon && <span class="ss_shell__nav_icon">{props.icon}</span>}
|
||||
<span class="ss_shell__nav_label">{props.title}</span>
|
||||
<span class="ss_shell__nav_group_chevron" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ss_shell__nav_group_chevron_svg"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ss_shell__nav_group_items">{props.children}</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
export { SSShell };
|
||||
15
src/components/SSSurface.tsx
Normal file
15
src/components/SSSurface.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
type SSSurfaceProps = {
|
||||
children: import('solid-js').JSXElement;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSSurface(props: SSSurfaceProps) {
|
||||
return (
|
||||
<div class={`ss_surface ${props.class ?? ''}`} style={props.style}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SSSurface };
|
||||
59
src/components/SSTile.tsx
Normal file
59
src/components/SSTile.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { JSXElement } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
type SSTileProps = {
|
||||
icon?: JSXElement;
|
||||
title: JSXElement;
|
||||
href?: string;
|
||||
subtitle?: JSXElement;
|
||||
trailing?: JSXElement;
|
||||
onLinkClick?: () => void;
|
||||
class?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
function SSTile(props: SSTileProps) {
|
||||
return (
|
||||
<div class={`ss_tile ${props.class ?? ''}`} style={props.style}>
|
||||
<div class="ss_tile__row">
|
||||
{props.icon && <span class="ss_tile__icon">{props.icon}</span>}
|
||||
<div class="ss_tile__content">
|
||||
{props.href ? (
|
||||
<h5 class="ss_tile__title">
|
||||
<A class="ss_tile__link" href={props.href} onclick={props.onLinkClick}>
|
||||
{props.title}
|
||||
</A>
|
||||
</h5>
|
||||
) : (
|
||||
<h5 class="ss_tile__title">
|
||||
<span class="ss_tile__text">{props.title}</span>
|
||||
</h5>
|
||||
)}
|
||||
{props.subtitle && <div class="ss_tile__subtitle">{props.subtitle}</div>}
|
||||
</div>
|
||||
{props.trailing && <div class="ss_tile__trailing">{props.trailing}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createSSTile<T>(build: (data: T) => SSTileProps) {
|
||||
return function (props: {
|
||||
data: T;
|
||||
noLink?: boolean;
|
||||
noIcon?: boolean;
|
||||
onLinkClick?: () => void;
|
||||
}) {
|
||||
const built = build(props.data);
|
||||
return (
|
||||
<SSTile
|
||||
{...built}
|
||||
onLinkClick={props.onLinkClick ?? built.onLinkClick}
|
||||
href={props.noLink ? undefined : built.href}
|
||||
icon={props.noIcon ? undefined : built.icon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export { SSTile, createSSTile };
|
||||
21
src/hooks/createLoading.ts
Normal file
21
src/hooks/createLoading.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Accessor, createSignal } from "solid-js";
|
||||
|
||||
type PromiseOr<T> = Promise<T> | T;
|
||||
|
||||
export function createLoading(): [
|
||||
Accessor<boolean>,
|
||||
(action: () => PromiseOr<void>) => Promise<void>,
|
||||
] {
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
return [loading, async (callback) => {
|
||||
if (loading()) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await callback();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}];
|
||||
}
|
||||
17
src/index.ts
Normal file
17
src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { SSButton } from './components/SSButton';
|
||||
export { SSCallout } from './components/SSCallout';
|
||||
export { SSChip } from './components/SSChip';
|
||||
export { SSAttrList } from './components/SSAttrList';
|
||||
export { SSDataTable } from './components/SSDataTable';
|
||||
export { SSDivider } from './components/SSDivider';
|
||||
export { SSDropdown } from './components/SSDropdown';
|
||||
export { SSExpandable } from './components/SSExpandable';
|
||||
export { SSForm } from './components/SSForm';
|
||||
export { SSHeader } from './components/SSHeader';
|
||||
export { SSModal } from './components/SSModal';
|
||||
export { SSModalsProvider, useSSModals } from './components/SSModals';
|
||||
export { SSShell } from './components/SSShell';
|
||||
export { SSSurface } from './components/SSSurface';
|
||||
export { SSTile, createSSTile } from './components/SSTile';
|
||||
|
||||
export type { SSColor } from './colors';
|
||||
2441
src/styles/default.css
Normal file
2441
src/styles/default.css
Normal file
File diff suppressed because it is too large
Load Diff
2312
src/styles/proposal.css
Normal file
2312
src/styles/proposal.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user