This commit is contained in:
fabio
2026-02-22 17:58:31 +01:00
parent 70e34465de
commit e069100c53
14 changed files with 519 additions and 24 deletions

19
ui-kit/src/base.css Normal file
View File

@@ -0,0 +1,19 @@
:root {
--ui-bg: #ffffff;
--ui-fg: #111827;
--ui-muted: #6b7280;
--ui-border: #d1d5db;
--ui-overlay: rgba(17, 24, 39, 0.56);
--ui-panel: #ffffff;
--ui-radius: 10px;
--ui-shadow: 0 10px 35px rgba(15, 23, 42, 0.2);
--ui-primary: #111827;
--ui-primary-contrast: #ffffff;
}
ui-modal,
ui-drop-down,
ui-data-table-shell {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: var(--ui-fg);
}

View File

@@ -0,0 +1,74 @@
<svelte:options customElement="ui-data-table-shell" />
<script lang="ts">
export let endpoint = '';
export let target = '';
export let pageSize = 10;
let query = '';
function submit(page = 1) {
if (!endpoint || !target) return;
const params = new URLSearchParams();
if (query.trim()) params.set('q', query.trim());
params.set('page', String(page));
params.set('pageSize', String(pageSize));
const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
const htmxApi = (window as any).htmx;
if (htmxApi && typeof htmxApi.ajax === 'function') {
htmxApi.ajax('GET', url, { target });
return;
}
const targetEl = document.querySelector(target);
if (!targetEl) return;
fetch(url)
.then((res) => res.text())
.then((html) => {
targetEl.innerHTML = html;
})
.catch(() => {
// no-op fallback
});
}
</script>
<form class="toolbar" on:submit|preventDefault={() => submit(1)}>
<input
type="search"
placeholder="Search..."
bind:value={query}
on:keydown={(e) => e.key === 'Enter' && submit(1)}
/>
<button type="submit">Search</button>
</form>
<style>
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
input[type='search'] {
flex: 1;
min-width: 180px;
padding: 10px;
border: 1px solid var(--ui-border);
border-radius: 8px;
}
button {
border: 0;
border-radius: 8px;
background: var(--ui-primary);
color: var(--ui-primary-contrast);
padding: 10px 12px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,153 @@
<svelte:options customElement="ui-drop-down" />
<script lang="ts">
import { onMount } from 'svelte';
export let value = '';
export let name = '';
export let placeholder = 'Select...';
export let disabled = false;
let rootEl: HTMLElement;
let host: HTMLElement;
let hiddenInput: HTMLInputElement;
let open = false;
type OptionItem = { value: string; label: string };
let options: OptionItem[] = [];
function loadOptions() {
options = Array.from(host.querySelectorAll('option')).map((opt) => ({
value: opt.value,
label: opt.textContent?.trim() || opt.value
}));
if (!value) {
const selected = host.querySelector('option[selected]') as HTMLOptionElement | null;
if (selected) value = selected.value;
}
}
function selectedLabel() {
if (!value) return placeholder;
return options.find((o) => o.value === value)?.label || placeholder;
}
function selectOption(nextValue: string) {
value = nextValue;
open = false;
syncHiddenInput();
host.dispatchEvent(new Event('change', { bubbles: true }));
host.dispatchEvent(
new CustomEvent('ui:change', {
detail: { value },
bubbles: true,
composed: true
})
);
}
function syncHiddenInput() {
if (!hiddenInput) return;
hiddenInput.name = name;
hiddenInput.value = value;
hiddenInput.disabled = disabled || !name;
}
onMount(() => {
host = (rootEl.getRootNode() as ShadowRoot).host as HTMLElement;
if (!host) return;
hiddenInput = host.querySelector('input[data-ui-dropdown-hidden="true"]') as HTMLInputElement;
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.setAttribute('data-ui-dropdown-hidden', 'true');
host.appendChild(hiddenInput);
}
loadOptions();
syncHiddenInput();
const observer = new MutationObserver(() => {
loadOptions();
syncHiddenInput();
});
observer.observe(host, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
});
</script>
<div class="dropdown" bind:this={rootEl}>
<button type="button" class="trigger" disabled={disabled} on:click={() => (open = !open)}>
<span>{selectedLabel()}</span>
<span aria-hidden="true"></span>
</button>
{#if open}
<div class="menu" role="listbox">
{#if options.length === 0}
<div class="empty">No options</div>
{:else}
{#each options as opt}
<button type="button" class="item" on:click={() => selectOption(opt.value)}>
{opt.label}
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.dropdown {
position: relative;
display: inline-block;
min-width: 180px;
}
.trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-bg);
cursor: pointer;
}
.menu {
position: absolute;
z-index: 30;
left: 0;
right: 0;
margin-top: 6px;
border: 1px solid var(--ui-border);
background: var(--ui-bg);
border-radius: 8px;
box-shadow: var(--ui-shadow);
overflow: hidden;
}
.item {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 10px 12px;
cursor: pointer;
}
.item:hover {
background: #f3f4f6;
}
.empty {
padding: 10px 12px;
color: var(--ui-muted);
}
</style>

View File

@@ -0,0 +1,142 @@
<svelte:options customElement="ui-modal" />
<script lang="ts">
import { onMount } from 'svelte';
export let title = '';
export let open = false;
let rootEl: HTMLElement;
let panelEl: HTMLElement;
function closeModal() {
open = false;
rootEl?.removeAttribute('open');
rootEl?.dispatchEvent(
new CustomEvent('ui:close', { bubbles: true, composed: true })
);
}
function onKeyDown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
event.preventDefault();
closeModal();
return;
}
if (event.key === 'Tab') {
trapFocus(event);
}
}
function trapFocus(event: KeyboardEvent) {
const focusables = panelEl?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
if (!focusables || focusables.length === 0) {
event.preventDefault();
panelEl?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
closeModal();
}
}
onMount(() => {
const host = (rootEl.getRootNode() as ShadowRoot).host as HTMLElement;
const observer = new MutationObserver(() => {
open = host.hasAttribute('open');
if (open) {
setTimeout(() => {
const autofocus = panelEl?.querySelector<HTMLElement>('[autofocus]');
(autofocus || panelEl)?.focus();
}, 0);
}
});
observer.observe(host, { attributes: true, attributeFilter: ['open'] });
return () => observer.disconnect();
});
</script>
{#if open}
<div class="overlay" on:click={onBackdropClick} on:keydown={onKeyDown} role="presentation">
<div class="panel" role="dialog" aria-modal="true" tabindex="-1" bind:this={panelEl}>
<header class="header">
<h3>{title}</h3>
<button type="button" class="close" on:click={closeModal} aria-label="Close">×</button>
</header>
<section class="body">
<slot />
</section>
</div>
</div>
{/if}
<div bind:this={rootEl} hidden></div>
<style>
.overlay {
position: fixed;
inset: 0;
background: var(--ui-overlay);
display: grid;
place-items: center;
z-index: 1000;
}
.panel {
width: min(640px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow: auto;
background: var(--ui-panel);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
outline: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--ui-border);
}
h3 {
margin: 0;
font-size: 1rem;
}
.close {
border: 0;
background: transparent;
font-size: 1.4rem;
cursor: pointer;
}
.body {
padding: 12px 16px 16px;
}
</style>

6
ui-kit/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import './base.css';
import './components/UiModal.svelte';
import './components/UiDropDown.svelte';
import './components/UiDataTableShell.svelte';
export {};