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

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

29
.eslintrc Normal file
View File

@@ -0,0 +1,29 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "no-only-tests", "eslint-comments"],
"ignorePatterns": ["node_modules", "dist", "dev", "tsup.config.ts", "vitest.config.ts"],
"parserOptions": {
"project": "./tsconfig.json",
"tsconfigRootDir": ".",
"sourceType": "module"
},
"rules": {
"prefer-const": "warn",
"no-console": "warn",
"no-debugger": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/no-useless-empty-export": "warn",
"no-only-tests/no-only-tests": "warn",
"eslint-comments/no-unused-disable": "warn"
}
}

31
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: "🐛 Bug report"
description: Create a report to help us improve
body:
- type: markdown
attributes:
value: |
Thank you for reporting an issue :pray:.
The more information you fill in, the better the community can help you.
- type: textarea
id: description
attributes:
label: Describe the bug
description: Provide a clear and concise description of the challenge you are running into.
validations:
required: true
- type: input
id: link
attributes:
label: Minimal Reproduction Link
description: |
Please provide a link to a minimal reproduction of the bug you are running into.
It makes the process of verifying and fixing the bug much easier.
Note:
- Your bug will may get fixed much faster if we can run your code and it doesn't have dependencies other than the solid-js and solid-primitives.
- To create a shareable code example you can use [Stackblitz](https://stackblitz.com/) (https://solid.new). Please no localhost URLs.
- Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve.
placeholder: |
e.g. https://stackblitz.com/edit/...... OR Github Repo
validations:
required: true

View File

@@ -0,0 +1,27 @@
name: "Feature Request"
description: For feature/enhancement requests. Please search for existing issues first.
body:
- type: markdown
attributes:
value: |
Thank you for bringing your ideas here :pray:.
The more information you fill in, the better the community can understand your idea.
- type: textarea
id: problem
attributes:
label: Describe The Problem To Be Solved
description: Provide a clear and concise description of the challenge you are running into.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Suggest A Solution
description: |
A concise description of your preferred solution. Things to address include:
- Details of the technical implementation
- Tradeoffs made in design decisions
- Caveats and considerations for the future
validations:
required: true

46
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: "CodeQL"
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "30 1 * * 0"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
with:
upload: false
output: sarif-results
# Only include files that are public
- name: filter-sarif
uses: advanced-security/filter-sarif@main
with:
patterns: |
/src/**/*.*
-**/*.test.*
input: sarif-results/javascript.sarif
output: sarif-results/javascript.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v1
with:
sarif_file: sarif-results/javascript.sarif

37
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Format
on:
push:
branches: [main]
jobs:
format:
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- name: Setup Node.js environment
uses: actions/setup-node@v3
with:
node-version: 18
# "git restore ." discards changes to package-lock.json
- name: Install dependencies
run: |
pnpm install --no-frozen-lockfile --ignore-scripts
git restore .
- name: Format
run: pnpm run format
- name: Add, Commit and Push
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: 'Format'

39
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Build and Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 2
- uses: pnpm/action-setup@v2.2.4
- name: Setup Node.js environment
uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Test
run: pnpm run test
env:
CI: true
- name: Lint
run: pnpm run lint

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 100,
"semi": false,
"singleQuote": true,
"useTabs": false,
"arrowParens": "avoid",
"bracketSpacing": true
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 {{me}}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
<p>
<img width="100%" src="https://assets.solidjs.com/banner?type=@sortsys/ui&background=tiles&project=%20" alt="@sortsys/ui">
</p>
# @sortsys/ui
Simple and beautiful UI library for sortsys
## Quick start
Install it:
```bash
npm i @sortsys/ui
# or
yarn add @sortsys/ui
# or
pnpm add @sortsys/ui
```
Use it:
```tsx
import { SSShell, SSForm } from '@sortsys/ui'
```

17
dev/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type { Component } from 'solid-js';
import type { RouteSectionProps } from '@solidjs/router';
import { HashRouter } from '@solidjs/router';
import { SSModalsProvider } from 'src';
import { routes } from './routes';
const AppRoot: Component<RouteSectionProps> = (props) => (
<SSModalsProvider>{props.children}</SSModalsProvider>
);
const App: Component = () => (
<HashRouter root={AppRoot}>
{routes}
</HashRouter>
);
export default App;

106
dev/demo/content.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { JSXElement } from 'solid-js';
import { SSChip } from 'src';
export const IconSpark = () => (
<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-sparkles"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2a2 2 0 0 1 -2 2a2 2 0 0 1 2 -2" /><path d="M12 4a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2a2 2 0 0 1 -2 2a2 2 0 0 1 2 -2" /><path d="M5 14a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2a2 2 0 0 1 -2 2a2 2 0 0 1 2 -2" /></svg>
);
export const IconBolt = () => (
<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-bolt"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13 3v6h6l-8 12v-6h-6l8 -12" /></svg>
);
export const IconShield = () => (
<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-shield"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 3l8 4v5c0 5 -3 9 -8 9s-8 -4 -8 -9v-5l8 -4" /></svg>
);
export const IconUser = () => (
<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-user"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
);
export const IconChart = () => (
<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-chart-bar"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 12m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v7a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M9 8m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v11a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M15 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v15a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M4 20l14 0" /></svg>
);
export const IconSettings = () => (
<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-settings"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg>
);
export const IconBell = () => (
<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-bell"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6" /><path d="M9 17v1a3 3 0 0 0 6 0v-1" /></svg>
);
export const IconCheck = () => (
<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>
);
export const IconLink = () => (
<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-link"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 15l6 -6" /><path d="M11 6l-2.5 2.5a3.535 3.535 0 0 0 5 5l2.5 -2.5" /><path d="M13 18l2.5 -2.5a3.535 3.535 0 0 0 -5 -5l-2.5 2.5" /></svg>
);
export const IconDots = () => (
<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>
);
export const demoRows = [
{
name: 'Lia Morgan',
role: 'Design Ops',
status: 'Active',
updated: 'Vor 2 Tagen',
},
{
name: 'Jonas Beck',
role: 'Frontend',
status: 'Review',
updated: 'Heute',
},
{
name: 'Ava Ruiz',
role: 'Support',
status: 'Active',
updated: 'Vor 1 Stunde',
},
{
name: 'Marek Ali',
role: 'Platform',
status: 'On hold',
updated: 'Vor 5 Tagen',
},
{
name: 'Sina Weber',
role: 'Growth',
status: 'Active',
updated: 'Gestern',
},
];
export const teamOptions = [
{ id: 'blue', label: 'Blue team' },
{ id: 'gold', label: 'Gold team' },
{ id: 'orchid', label: 'Orchid team' },
];
export const peopleOptions = [
{ id: 'lia', name: 'Lia Morgan', role: 'Design Ops' },
{ id: 'jonas', name: 'Jonas Beck', role: 'Frontend' },
{ id: 'ava', name: 'Ava Ruiz', role: 'Support' },
{ id: 'marek', name: 'Marek Ali', role: 'Platform' },
{ id: 'sina', name: 'Sina Weber', role: 'Growth' },
{ id: 'tom', name: 'Tom Richter', role: 'Security' },
];
export const stackSuggestions = [
'SolidStart',
'SolidJS',
'TypeScript',
'Vite',
'Tsup',
'Tailwind',
'Radix',
];
export const renderStatus = (status: string): JSXElement => (
<SSChip color={status === 'Active' ? 'green' : status === 'Review' ? 'amber' : 'grey'}>
{status}
</SSChip>
);

18
dev/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>@sortsys/ui demo</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="./index.tsx" type="module"></script>
</body>
</html>

7
dev/index.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { render } from 'solid-js/web'
import 'src/styles/default.css'
import './styles.css'
import App from './App'
render(() => <App />, document.getElementById('root')!)

1
dev/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

39
dev/routes.ts Normal file
View File

@@ -0,0 +1,39 @@
import { lazy } from 'solid-js';
import type { RouteDefinition } from '@solidjs/router';
export const routes: RouteDefinition[] = [
{
path: '/auth/login',
component: lazy(() => import('./routes/auth/login')),
},
{
path: '/',
component: lazy(() => import('./routes/(shell)/_layout')),
children: [
{
path: '/',
component: lazy(() => import('./routes/(shell)/index')),
},
{
path: '/forms',
component: lazy(() => import('./routes/(shell)/forms')),
},
{
path: '/data',
component: lazy(() => import('./routes/(shell)/data')),
},
{
path: '/modals',
component: lazy(() => import('./routes/(shell)/modals')),
},
{
path: '/layout',
component: lazy(() => import('./routes/(shell)/layout')),
},
{
path: '*',
component: lazy(() => import('./routes/(shell)/_404')),
},
],
},
];

View File

@@ -0,0 +1,17 @@
import type { Component } from 'solid-js';
import { SSButton, SSCallout, SSHeader, SSSurface } from 'src';
import { IconShield } from '../../demo/content';
const NotFoundPage: Component = () => (
<div class="demo_page">
<SSHeader title="Not found" subtitle="This route is not available in the demo." />
<SSSurface class="demo_surface">
<SSCallout color="amber" icon={<IconShield />}>
The page you requested does not exist. Head back to the overview to continue exploring.
</SSCallout>
<SSButton onclick={() => (window.location.href = '#/')}>Back to overview</SSButton>
</SSSurface>
</div>
);
export default NotFoundPage;

View File

@@ -0,0 +1,73 @@
import type { Component } from 'solid-js';
import type { RouteSectionProps } from '@solidjs/router';
import { createSignal } from 'solid-js';
import {
SSButton,
SSCallout,
SSDropdown,
SSShell,
} from 'src';
import {
IconBell,
IconChart,
IconDots,
IconLink,
IconSettings,
IconShield,
IconSpark,
IconBolt,
IconCheck,
} from '../../demo/content';
const ShellLayout: Component<RouteSectionProps> = (props) => {
const [notice, setNotice] = createSignal(false);
return (
<SSShell
title="Sortsys UI"
actions={
<div class="demo_inline">
<SSButton isIconOnly ariaLabel="Notifications">
<IconBell />
</SSButton>
<SSButton class="secondary">Export</SSButton>
<SSDropdown
icon={<IconDots />}
items={[
{ label: 'Settings', icon: <IconSettings /> },
{ label: 'Share', icon: <IconLink /> },
]}
/>
</div>
}
nav={
<SSShell.Nav>
<SSShell.NavLink href="/" icon={<IconSpark />}>Overview</SSShell.NavLink>
<SSShell.NavLink href="/forms" icon={<IconBolt />}>Forms</SSShell.NavLink>
<SSShell.NavLink href="/data" icon={<IconChart />}>Data</SSShell.NavLink>
<SSShell.NavLink href="/modals" icon={<IconShield />}>Modals</SSShell.NavLink>
<SSShell.NavGroup title="Layout" icon={<IconSettings />} initiallyExpanded>
<SSShell.NavLink href="/layout" icon={<IconLink />}>Surfaces & tiles</SSShell.NavLink>
</SSShell.NavGroup>
<SSShell.NavAction onclick={() => setNotice(true)} icon={<IconBell />}>
Ping team
</SSShell.NavAction>
</SSShell.Nav>
}
>
{notice() && (
<div class="demo_notice">
<SSCallout color="green" icon={<IconCheck />}>
Team notified. The update will appear in the activity feed.
</SSCallout>
<SSButton class="tertiary" onclick={() => setNotice(false)}>
Clear
</SSButton>
</div>
)}
{props.children}
</SSShell>
);
};
export default ShellLayout;

View File

@@ -0,0 +1,43 @@
import type { Component } from 'solid-js';
import { createMemo } from 'solid-js';
import { SSDataTable, SSHeader, SSSurface } from 'src';
import { demoRows, renderStatus } from '../../demo/content';
const DataPage: Component = () => {
const columns = createMemo(() => [
{
label: 'Name',
render: (row: (typeof demoRows)[number]) => row.name,
sortKey: (row: (typeof demoRows)[number]) => row.name,
},
{
label: 'Role',
render: (row: (typeof demoRows)[number]) => row.role,
sortKey: (row: (typeof demoRows)[number]) => row.role,
},
{
label: 'Status',
render: (row: (typeof demoRows)[number]) => renderStatus(row.status),
sortKey: (row: (typeof demoRows)[number]) => row.status,
},
{
label: 'Updated',
render: (row: (typeof demoRows)[number]) => row.updated,
sortKey: (row: (typeof demoRows)[number]) => row.updated,
},
]);
return (
<div class="demo_page">
<SSHeader title="Data table" subtitle="Sorting, paging, and quick actions." />
<SSDataTable
columns={columns()}
rows={demoRows}
pageSize={3}
onRowClick={async () => {}}
/>
</div>
);
};
export default DataPage;

View File

@@ -0,0 +1,118 @@
import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import {
SSChip,
SSDivider,
SSForm,
SSHeader,
SSSurface,
} from 'src';
import { peopleOptions, stackSuggestions, teamOptions } from '../../demo/content';
const FormsPage: Component = () => {
const [result, setResult] = createSignal<string | null>(null);
return (
<div class="demo_page">
<SSHeader title="Forms" subtitle="Inputs, selections, and validation in one place." />
<SSSurface class="demo_surface">
<SSForm
onsubmit={async (context) => {
const values = await context.getValues();
setResult(JSON.stringify(values, null, 2));
}}
>
<div class="demo_form_grid">
<SSForm.Input label="Project name" name="project" required />
<SSForm.Input label="Email" name="email" type="email" required />
<SSForm.Input label="Password" name="password" type="password" required />
<SSForm.Input label="Contact phone" name="phone" type="tel" />
</div>
<SSForm.Input
label="Message"
name="message"
textArea
/>
<SSForm.Input
label="Tech stack"
name="stack"
suggestions={{
prepare: () => stackSuggestions,
getItems: ({ query, init }) =>
init.filter((item) => item.toLowerCase().includes(query)).slice(0, 5),
stringify: ({ item }) => item,
}}
/>
<div class="demo_form_grid">
<SSForm.Date label="Start date" name="startDate" editable />
<SSForm.Date label="Target date" name="targetDate" />
</div>
<div class="demo_form_grid">
<SSForm.Select
label="Team"
name="team"
getOptions={() => teamOptions}
buildOption={(item) => item.label}
/>
<SSForm.Checkbox label="Notify stakeholders" name="notify" />
</div>
<SSDivider />
<SSForm.ACSelect
label="Invite members"
name="members"
minSelectedItems={1}
getOptions={({ query }) =>
peopleOptions.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
)
}
renderItem={({ item }) => (
<div class="demo_ac_item">
<strong>{item.name}</strong>
<span>{item.role}</span>
</div>
)}
/>
<SSForm.ACSelect
label="Primary owner"
name="owner"
maxSelectedItems={1}
getOptions={({ query }) =>
peopleOptions.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
)
}
renderItem={({ item }) => (
<div class="demo_ac_item">
<strong>{item.name}</strong>
<span>{item.role}</span>
</div>
)}
renderSelection={({ item }) => (
<SSChip color="indigo">{item.name}</SSChip>
)}
/>
<SSForm.SubmitButton>Save form</SSForm.SubmitButton>
</SSForm>
</SSSurface>
{result() && (
<SSSurface class="demo_surface">
<div class="demo_section_title">Submitted values</div>
<pre class="demo_code">{result()}</pre>
</SSSurface>
)}
</div>
);
};
export default FormsPage;

View File

@@ -0,0 +1,202 @@
import type { Component } from 'solid-js';
import { For, createSignal } from 'solid-js';
import {
SSAttrList,
SSButton,
SSCallout,
SSChip,
SSDivider,
SSDropdown,
SSExpandable,
SSHeader,
SSSurface,
SSTile,
} from 'src';
import {
IconBolt,
IconChart,
IconDots,
IconLink,
IconSettings,
IconShield,
IconSpark,
IconUser,
IconBell,
IconCheck,
} from '../../demo/content';
const OverviewPage: Component = () => {
const [bannerVisible, setBannerVisible] = createSignal(true);
const [chipItems, setChipItems] = createSignal([
{ id: 'launch', label: 'Launch ready', color: 'green' },
{ id: 'urgent', label: 'Urgent', color: 'red' },
{ id: 'info', label: 'Info', color: 'blue' },
]);
return (
<div class="demo_page">
<SSHeader
title="Sortsys UI"
subtitle="A crisp SolidJS component suite for operational products."
actions={
<div class="demo_inline">
<SSButton class="secondary">Preview</SSButton>
<SSButton>Publish</SSButton>
<SSDropdown
ariaLabel="Aktionen"
icon={<IconDots />}
items={[
{ label: 'Duplicate', icon: <IconSpark /> },
{ label: 'Share link', icon: <IconLink />, checked: true },
{ label: 'Archive', icon: <IconShield /> },
]}
/>
</div>
}
/>
{bannerVisible() && (
<div class="demo_notice">
<SSCallout color="blue" icon={<IconSpark />}>
A fresh demo layout that shows every component in context.
</SSCallout>
<SSButton class="tertiary" onclick={() => setBannerVisible(false)}>
Dismiss
</SSButton>
</div>
)}
<div class="demo_grid">
<SSSurface class="demo_surface">
<div class="demo_section_title">Status tiles</div>
<div class="demo_tiles">
<SSTile
title="Design review"
subtitle="Last updated 2 hours ago"
icon={<IconSpark />}
trailing="92%"
/>
<SSTile
title="Security sweep"
subtitle="Next window tomorrow"
icon={<IconShield />}
trailing="4 issues"
/>
<SSTile
title="Capacity"
subtitle="Quarterly planning"
icon={<IconChart />}
trailing="14 slots"
/>
</div>
</SSSurface>
<SSSurface class="demo_surface">
<div class="demo_section_title">Quick actions</div>
<div class="demo_buttons">
<SSButton>Primary action</SSButton>
<SSButton class="secondary">Secondary</SSButton>
<SSButton class="tertiary">Tertiary</SSButton>
<SSButton class="danger">Danger</SSButton>
<SSButton isIconOnly ariaLabel="Notifications">
<IconBell />
</SSButton>
</div>
<SSDivider />
<div class="demo_section_title">Chips</div>
<div class="demo_chips">
<For each={chipItems()}>
{(item) => (
<SSChip
color={item.color as any}
ondismiss={() =>
setChipItems((prev) => prev.filter((chip) => chip.id !== item.id))
}
>
{item.label}
</SSChip>
)}
</For>
<SSChip color="purple" onclick={() => {}}>
Clickable
</SSChip>
<SSChip color="amber">Display only</SSChip>
</div>
</SSSurface>
</div>
<SSSurface class="demo_surface">
<div class="demo_section_title">Attribute list</div>
<SSAttrList>
<SSAttrList.Attr
name="Lifecycle"
value="Production"
third={
<SSButton isIconOnly class="tertiary" ariaLabel="Edit">
<IconSettings />
</SSButton>
}
/>
<SSAttrList.Attr
name="Owner"
value="Customer Ops"
third="2 teams, 6 contributors"
/>
<SSAttrList.Attr name="Uptime" value="99.98%" third="Rolling 30 days" />
<SSAttrList.Attr name="Latency" value="142ms avg" third="p95 down 12%" />
<SSAttrList.Attr
name="Alerts"
value="2 open"
third="1 critical"
/>
<SSAttrList.Attr
name="Risk"
value="Low"
third="Review passed"
/>
</SSAttrList>
</SSSurface>
<div class="demo_grid">
<SSSurface class="demo_surface">
<div class="demo_section_title">Callouts</div>
<div class="demo_stack">
<SSCallout color="green" icon={<IconCheck />}>
All services are green. Next audit is scheduled for Friday.
</SSCallout>
<SSCallout color="amber" icon={<IconBolt />}>
2 alerts are pending review in the on-call queue.
</SSCallout>
<SSCallout color="red" icon={<IconShield />}>
Credentials rotation required within 14 days.
</SSCallout>
</div>
</SSSurface>
<SSSurface class="demo_surface">
<div class="demo_section_title">Expandable</div>
<SSExpandable title="Deployment checklist" initiallyExpanded>
<div class="demo_stack">
<div class="demo_inline">
<IconCheck />
<span>Review release notes and merge outstanding fixes.</span>
</div>
<div class="demo_inline">
<IconCheck />
<span>Warm up cache nodes and verify rollout.</span>
</div>
<div class="demo_inline">
<IconCheck />
<span>Post update to #ops and notify stakeholders.</span>
</div>
</div>
</SSExpandable>
</SSSurface>
</div>
</div>
);
};
export default OverviewPage;

View File

@@ -0,0 +1,61 @@
import type { Component } from 'solid-js';
import {
SSDivider,
SSExpandable,
SSHeader,
SSSurface,
SSTile,
} from 'src';
import { IconBolt, IconCheck, IconLink, IconShield } from '../../demo/content';
const LayoutPage: Component = () => (
<div class="demo_page">
<SSHeader title="Layout" subtitle="Surfaces, tiles, and dividers." />
<SSSurface class="demo_surface">
<div class="demo_section_title">Surface stacks</div>
<div class="demo_grid">
<SSSurface class="demo_surface demo_surface--nested">
<div class="demo_section_title">Sub-surface</div>
<p class="demo_text">Perfect for grouped content, metrics, or compact forms.</p>
</SSSurface>
<SSSurface class="demo_surface demo_surface--nested">
<div class="demo_section_title">Another surface</div>
<p class="demo_text">Use consistent radii and subtle shadows for hierarchy.</p>
</SSSurface>
</div>
</SSSurface>
<SSSurface class="demo_surface">
<div class="demo_section_title">Tile list</div>
<div class="demo_tiles">
<SSTile
title="Integrations"
subtitle="24 connected services"
icon={<IconLink />}
trailing={<IconCheck />}
/>
<SSTile
title="Automation"
subtitle="5 workflows running"
icon={<IconBolt />}
trailing="Live"
/>
<SSTile
title="Security"
subtitle="Next scan Friday"
icon={<IconShield />}
trailing="Low risk"
/>
</div>
<SSDivider />
<SSExpandable title="Notes">
<div class="demo_text">
Combine tiles with expandables to progressively reveal details.
</div>
</SSExpandable>
</SSSurface>
</div>
);
export default LayoutPage;

View File

@@ -0,0 +1,103 @@
import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import {
SSAttrList,
SSButton,
SSCallout,
SSForm,
SSHeader,
SSModal,
SSSurface,
useSSModals,
} from 'src';
import { IconSpark } from '../../demo/content';
const ModalsPage: Component = () => {
const modals = useSSModals();
const [open, setOpen] = createSignal(false);
return (
<div class="demo_page">
<SSHeader title="Modals" subtitle="Inline and managed modal flows." />
<SSSurface class="demo_surface">
<div class="demo_inline">
<SSButton onclick={() => setOpen(true)}>Open modal</SSButton>
<SSButton
class="secondary"
onclick={() =>
modals.showDefault({
content: () => (
<div class="demo_stack">
<strong>Delete pipeline?</strong>
<span>This action will remove the pipeline and its history.</span>
</div>
),
modalProps: () => ({
title: 'Delete confirmation',
danger: true,
primaryButtonText: 'Delete',
}),
})
}
>
Default modal
</SSButton>
<SSButton
class="tertiary"
onclick={() =>
modals.showForm({
content: ({ context }) => (
<div class="demo_stack">
<SSForm.Input label="Change label" name="label" required />
<SSForm.Input label="Summary" name="summary" textArea />
<SSForm.Checkbox label="Notify watchers" name="notify" />
{context.hasError() && (
<span class="demo_hint">Please fix the highlighted fields.</span>
)}
</div>
),
modalProps: () => ({
title: 'Edit release',
primaryButtonText: 'Save',
}),
onSubmit: async ({ hide }) => {
hide();
},
})
}
>
Form modal
</SSButton>
</div>
</SSSurface>
<SSModal
open={open()}
title="Launch overview"
onClose={() => setOpen(false)}
footer={
<>
<SSButton class="secondary" onclick={() => setOpen(false)}>
Cancel
</SSButton>
<SSButton onclick={() => setOpen(false)}>Confirm</SSButton>
</>
}
>
<div class="demo_stack">
<SSCallout color="blue" icon={<IconSpark />}>
Everything is ready to go. Confirm to publish.
</SSCallout>
<SSAttrList>
<SSAttrList.Attr name="Version" value="v1.18.0" />
<SSAttrList.Attr name="Owner" value="Release team" />
<SSAttrList.Attr name="Window" value="Tomorrow 09:00 CET" />
</SSAttrList>
</div>
</SSModal>
</div>
);
};
export default ModalsPage;

25
dev/routes/auth/login.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Component } from 'solid-js';
import { SSButton, SSCallout, SSForm, SSHeader, SSSurface } from 'src';
import { IconSpark } from '../../demo/content';
const LoginPage: Component = () => (
<div class="demo_page">
<SSHeader title="Sign in" subtitle="Access the demo workspace." />
<SSSurface class="demo_surface">
<SSCallout color="blue" icon={<IconSpark />}>
Use any email and password to explore the UI kit.
</SSCallout>
<SSForm onsubmit={() => {}}>
<SSForm.Input label="Email" name="email" type="email" required />
<SSForm.Input label="Password" name="password" type="password" required />
<SSForm.Checkbox label="Remember me" name="remember" />
<div class="demo_inline">
<SSButton>Sign in</SSButton>
<SSButton class="secondary">Request access</SSButton>
</div>
</SSForm>
</SSSurface>
</div>
);
export default LoginPage;

111
dev/styles.css Normal file
View File

@@ -0,0 +1,111 @@
.demo_page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.demo_section_title {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-muted);
}
.demo_grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.demo_surface {
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo_surface--nested {
box-shadow: none;
}
.demo_tiles {
display: grid;
gap: 0.75rem;
}
.demo_buttons {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.demo_chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.demo_stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.demo_inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.demo_form_grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.demo_ac_item {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.demo_ac_item strong {
font-size: 0.9rem;
color: var(--fg-primary);
}
.demo_ac_item span {
font-size: 0.75rem;
color: var(--fg-muted);
}
.demo_text {
margin: 0;
color: var(--fg-secondary);
font-size: 0.9rem;
}
.demo_code {
margin: 0;
padding: 1rem;
background: var(--bg-surface-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: 0.8rem;
color: var(--fg-primary);
white-space: pre-wrap;
}
.demo_hint {
font-size: 0.8rem;
color: var(--fg-muted);
}
.demo_notice {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}

7
dev/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["vite/client"]
},
"exclude": ["node_modules", "dist"]
}

37
dev/vite.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import solidPlugin from 'vite-plugin-solid'
export default defineConfig({
resolve: {
alias: {
src: path.resolve(__dirname, '../src'),
},
},
plugins: [
solidPlugin(),
{
name: 'Reaplace env variables',
transform(code, id) {
if (id.includes('node_modules')) {
return code
}
return code
.replace(/process\.env\.SSR/g, 'false')
.replace(/process\.env\.DEV/g, 'true')
.replace(/process\.env\.PROD/g, 'false')
.replace(/process\.env\.NODE_ENV/g, '"development"')
.replace(/import\.meta\.env\.SSR/g, 'false')
.replace(/import\.meta\.env\.DEV/g, 'true')
.replace(/import\.meta\.env\.PROD/g, 'false')
.replace(/import\.meta\.env\.NODE_ENV/g, '"development"')
},
},
],
server: {
port: 3001,
},
build: {
target: 'esnext',
},
})

18
env.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
declare global {
interface ImportMeta {
env: {
NODE_ENV: 'production' | 'development'
PROD: boolean
DEV: boolean
}
}
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'production' | 'development'
PROD: boolean
DEV: boolean
}
}
}
export {}

6439
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

92
package.json Normal file
View File

@@ -0,0 +1,92 @@
{
"name": "@sortsys/ui",
"version": "0.1.9",
"description": "Simple and beautiful UI library for sortsys",
"license": "MIT",
"author": "Ludwig Lehnert",
"contributors": [],
"repository": {
"type": "git",
"url": "git+https://github.com/stuck-lehnert/sortsys-ui.git"
},
"homepage": "https://github.com/stuck-lehnert/sortsys-ui#readme",
"bugs": {
"url": "https://github.com/stuck-lehnert/sortsys-ui/issues"
},
"files": [
"dist"
],
"private": false,
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": {},
"exports": {
".": {
"development": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/dev.js"
}
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./styles/*": "./dist/styles/*"
},
"typesVersions": {},
"scripts": {
"dev": "vite serve dev",
"build": "tsup && esbuild src/styles/default.css --minify --outfile=dist/styles/default.css",
"test": "concurrently pnpm:test:*",
"test:client": "vitest",
"test:ssr": "npm run test:client --mode ssr",
"prepublishOnly": "npm run build",
"format": "prettier --ignore-path .gitignore -w \"src/**/*.{js,ts,json,css,tsx,jsx}\" \"dev/**/*.{js,ts,json,css,tsx,jsx}\"",
"lint": "concurrently pnpm:lint:*",
"lint:code": "eslint --ignore-path .gitignore --max-warnings 0 src/**/*.{js,ts,tsx,jsx}",
"lint:types": "tsc --noEmit",
"update-deps": "pnpm up -Li"
},
"peerDependencies": {
"solid-js": "^1.6.0"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
"concurrently": "^8.2.2",
"esbuild": "^0.21.3",
"esbuild-plugin-solid": "^0.6.0",
"eslint": "^8.56.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"jsdom": "^24.0.0",
"prettier": "3.0.0",
"solid-js": "^1.8.17",
"tsup": "^8.0.2",
"tsup-preset-solid": "^2.2.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.6.0"
},
"keywords": [
"solid"
],
"packageManager": "pnpm@9.1.1",
"engines": {
"node": ">=18",
"pnpm": ">=9.0.0"
},
"dependencies": {
"@solidjs/router": "^0.15.4"
},
"publishConfig": {
"access": "public"
}
}

4199
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

51
test/index.test.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { createRoot, createSignal } from 'solid-js'
import { isServer } from 'solid-js/web'
import { describe, expect, it } from 'vitest'
import { Hello, createHello } from '../src'
describe('environment', () => {
it('runs on client', () => {
expect(typeof window).toBe('object')
expect(isServer).toBe(false)
})
})
describe('createHello', () => {
it('Returns a Hello World signal', () =>
createRoot(dispose => {
const [hello] = createHello()
expect(hello()).toBe('Hello World!')
dispose()
}))
it('Changes the hello target', () =>
createRoot(dispose => {
const [hello, setHello] = createHello()
setHello('Solid')
expect(hello()).toBe('Hello Solid!')
dispose()
}))
})
describe('Hello', () => {
it('renders a hello component', () => {
createRoot(() => {
const container = (<Hello />) as HTMLDivElement
expect(container.outerHTML).toBe('<div>Hello World!</div>')
})
})
it('changes the hello target', () =>
createRoot(dispose => {
const [to, setTo] = createSignal('Solid')
const container = (<Hello to={to()} />) as HTMLDivElement
expect(container.outerHTML).toBe('<div>Hello Solid!</div>')
setTo('Tests')
// rendering is async
queueMicrotask(() => {
expect(container.outerHTML).toBe('<div>Hello Tests!</div>')
dispose()
})
}))
})

30
test/server.test.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { isServer, renderToString } from 'solid-js/web'
import { Hello, createHello } from '../src'
describe('environment', () => {
it('runs on server', () => {
expect(typeof window).toBe('undefined')
expect(isServer).toBe(true)
})
})
describe('createHello', () => {
it('Returns a Hello World signal', () => {
const [hello] = createHello()
expect(hello()).toBe('Hello World!')
})
it('Changes the hello target', () => {
const [hello, setHello] = createHello()
setHello('Solid')
expect(hello()).toBe('Hello Solid!')
})
})
describe('Hello', () => {
it('renders a hello component', () => {
const string = renderToString(() => <Hello />)
expect(string).toBe('<div>Hello World!</div>')
})
})

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": [],
"baseUrl": "."
},
"exclude": ["node_modules", "dist", "./dev"]
}

58
tsup.config.ts Normal file
View File

@@ -0,0 +1,58 @@
import { defineConfig } from 'tsup'
import * as preset from 'tsup-preset-solid'
const preset_options: preset.PresetOptions = {
// array or single object
entries: [
// default entry (index)
{
// entries with '.tsx' extension will have `solid` export condition generated
entry: 'src/index.ts',
// will generate a separate development entry
dev_entry: true,
},
],
// Set to `true` to remove all `console.*` calls and `debugger` statements in prod builds
drop_console: true,
// Set to `true` to generate a CommonJS build alongside ESM
// cjs: true,
}
const CI =
process.env['CI'] === 'true' ||
process.env['GITHUB_ACTIONS'] === 'true' ||
process.env['CI'] === '"1"' ||
process.env['GITHUB_ACTIONS'] === '"1"'
export default defineConfig(config => {
const watching = !!config.watch
const parsed_options = preset.parsePresetOptions(preset_options, watching)
if (!watching && !CI) {
const package_fields = preset.generatePackageExports(parsed_options)
const export_keys = Object.keys(package_fields.exports || {})
const is_condition_exports = export_keys.length > 0 && export_keys.every(key => !key.startsWith('.'))
if (is_condition_exports) {
package_fields.exports = {
'.': package_fields.exports,
'./styles/*': './dist/styles/*',
}
} else {
package_fields.exports = {
...package_fields.exports,
'./styles/*': './dist/styles/*',
}
}
console.log(`package.json: \n\n${JSON.stringify(package_fields, null, 2)}\n\n`)
// will update ./package.json with the correct export fields
preset.writePackageJson(package_fields)
}
return preset.generateTsupOptions(parsed_options).map(opts => ({
...opts,
// minify: !watching,
}));
})

42
vitest.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig } from 'vitest/config'
import solidPlugin from 'vite-plugin-solid'
export default defineConfig(({ mode }) => {
// to test in server environment, run with "--mode ssr" or "--mode test:ssr" flag
// loads only server.test.ts file
const testSSR = mode === 'test:ssr' || mode === 'ssr'
return {
plugins: [
solidPlugin({
// https://github.com/solidjs/solid-refresh/issues/29
hot: false,
// For testing SSR we need to do a SSR JSX transform
solid: { generate: testSSR ? 'ssr' : 'dom' },
}),
],
test: {
watch: false,
isolate: !testSSR,
env: {
NODE_ENV: testSSR ? 'production' : 'development',
DEV: testSSR ? '' : '1',
SSR: testSSR ? '1' : '',
PROD: testSSR ? '1' : '',
},
environment: testSSR ? 'node' : 'jsdom',
transformMode: { web: [/\.[jt]sx$/] },
...(testSSR
? {
include: ['test/server.test.{ts,tsx}'],
}
: {
include: ['test/*.test.{ts,tsx}'],
exclude: ['test/server.test.{ts,tsx}'],
}),
},
resolve: {
conditions: testSSR ? ['node'] : ['browser', 'development'],
},
}
})