initial commit
Some checks failed
CodeQL / Analyze (push) Failing after 1m32s

This commit is contained in:
Ludwig Lehnert
2026-01-11 11:08:48 +01:00
commit 0efd3d954b
58 changed files with 19390 additions and 0 deletions

23
src/colors.ts Normal file
View 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];

View 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 };

View 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 };

View 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
View 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 };

View 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 };

View 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 };

View 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 };

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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 };

View 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
View 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 };

View 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
View 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

File diff suppressed because it is too large Load Diff

2312
src/styles/proposal.css Normal file

File diff suppressed because it is too large Load Diff