summaryrefslogtreecommitdiffstats
path: root/apps/web-shared/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web-shared/src/components')
-rw-r--r--apps/web-shared/src/components/alert.svelte66
-rw-r--r--apps/web-shared/src/components/button.svelte116
-rw-r--r--apps/web-shared/src/components/chip.svelte50
-rw-r--r--apps/web-shared/src/components/details.svelte35
-rw-r--r--apps/web-shared/src/components/dropdown.svelte374
-rw-r--r--apps/web-shared/src/components/form/index.ts5
-rw-r--r--apps/web-shared/src/components/form/textarea.svelte48
-rw-r--r--apps/web-shared/src/components/icon.svelte87
-rw-r--r--apps/web-shared/src/components/menu/index.ts9
-rw-r--r--apps/web-shared/src/components/menu/item.svelte8
-rw-r--r--apps/web-shared/src/components/menu/menu.svelte54
-rw-r--r--apps/web-shared/src/components/menu/separator.svelte2
-rw-r--r--apps/web-shared/src/components/modal.svelte66
-rw-r--r--apps/web-shared/src/components/pre-header.svelte37
-rw-r--r--apps/web-shared/src/components/stopwatch.svelte161
-rw-r--r--apps/web-shared/src/components/table/index.ts15
-rw-r--r--apps/web-shared/src/components/table/paginator.svelte101
-rw-r--r--apps/web-shared/src/components/table/table.svelte3
-rw-r--r--apps/web-shared/src/components/table/tbody.svelte3
-rw-r--r--apps/web-shared/src/components/table/tcell.svelte23
-rw-r--r--apps/web-shared/src/components/table/thead.svelte10
-rw-r--r--apps/web-shared/src/components/table/trow.svelte6
-rw-r--r--apps/web-shared/src/components/tile.svelte4
23 files changed, 1283 insertions, 0 deletions
diff --git a/apps/web-shared/src/components/alert.svelte b/apps/web-shared/src/components/alert.svelte
new file mode 100644
index 0000000..4771f78
--- /dev/null
+++ b/apps/web-shared/src/components/alert.svelte
@@ -0,0 +1,66 @@
+<script>
+ import {afterUpdate} from "svelte";
+
+ export let title = "";
+ export let message = "";
+ export let type = "info";
+ export let visible = true;
+ export let closeable = false;
+
+ afterUpdate(() => {
+ if (type === "default") {
+ type = "primary";
+ }
+ });
+</script>
+
+<div class="alert alert--{type} padding-sm radius-md"
+ class:alert--is-visible={visible}
+ role="alert">
+ <div class="flex justify-between">
+ <div class="flex flex-row items-center">
+ <svg class="icon icon--sm alert__icon margin-right-xxs"
+ viewBox="0 0 24 24"
+ aria-hidden="true">
+ <path d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12s12-5.383,12-12S18.617,0,12,0z M14.658,18.284 c-0.661,0.26-2.952,1.354-4.272,0.191c-0.394-0.346-0.59-0.785-0.59-1.318c0-0.998,0.328-1.868,0.919-3.957 c0.104-0.395,0.231-0.907,0.231-1.313c0-0.701-0.266-0.887-0.987-0.887c-0.352,0-0.742,0.125-1.095,0.257l0.195-0.799 c0.787-0.32,1.775-0.71,2.621-0.71c1.269,0,2.203,0.633,2.203,1.837c0,0.347-0.06,0.955-0.186,1.375l-0.73,2.582 c-0.151,0.522-0.424,1.673-0.001,2.014c0.416,0.337,1.401,0.158,1.887-0.071L14.658,18.284z M13.452,8c-0.828,0-1.5-0.672-1.5-1.5 s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S14.28,8,13.452,8z"></path>
+ </svg>
+ {#if title}
+ <p class="text-sm">
+ <strong class="error-title">{title}</strong>
+ </p>
+ {:else if message}
+ <div class="text-component text-sm break-word">
+ {@html message}
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <button class="reset alert__close-btn"
+ on:click={() => visible = false}>
+ <svg class="icon"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2">
+ <title>Close alert</title>
+ <line x1="3"
+ y1="3"
+ x2="17"
+ y2="17"/>
+ <line x1="17"
+ y1="3"
+ x2="3"
+ y2="17"/>
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ {#if message && title}
+ <div class="text-component text-sm break-word padding-top-xs">
+ {@html message}
+ </div>
+ {/if}
+</div>
diff --git a/apps/web-shared/src/components/button.svelte b/apps/web-shared/src/components/button.svelte
new file mode 100644
index 0000000..5eaf19f
--- /dev/null
+++ b/apps/web-shared/src/components/button.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import Icon from "$shared/components/icon.svelte";
+
+ export let text = "";
+ export let title = "";
+ export let href = "";
+ export let variant: "primary"|"secondary"|"subtle" = "primary";
+ export let type: "button"|"submit"|"reset" = "button";
+ export let disabled = false;
+ export let loading = false;
+ export let icon = "";
+ export let icon_right_aligned = false;
+ export let icon_width = false;
+ export let icon_height = false;
+ export let id;
+ export let tabindex;
+ export let style;
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ "aria-controls": ($$restProps["aria-controls"] ?? "") || null,
+ class: [variant === "reset" ? "reset" : `btn btn--${variant} btn--preserve-width ${loading ? "btn--state-b" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+</script>
+
+<template>
+ {#if href && !disabled}
+ <a {href}
+ {...shared_props}
+ on:click>
+ <span class="btn__content-a">
+ {#if icon !== ""}
+ {#if icon_right_aligned}
+ {text}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {:else}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {text}
+ {/if}
+ {:else}
+ {text}
+ {/if}
+ </span>
+ {#if variant !== "reset" && loading}
+ <span class="btn__content-b">
+ <svg class="icon icon--is-spinning"
+ aria-hidden="true"
+ viewBox="0 0 16 16">
+ <title>Loading</title>
+ <g stroke-width="1"
+ fill="currentColor"
+ stroke="currentColor">
+ <path d="M.5,8a7.5,7.5,0,1,1,1.91,5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"/>
+ </g>
+ </svg>
+ </span>
+ {/if}
+ </a>
+ {:else}
+ <button {...shared_props}
+ on:click>
+ <span class="btn__content-a">
+ {#if icon !== ""}
+ {#if icon_right_aligned}
+ {text}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {:else}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {text}
+ {/if}
+ {:else}
+ {text}
+ {/if}
+ </span>
+ {#if variant !== "reset" && loading}
+ <span class="btn__content-b">
+ <svg class="icon icon--is-spinning"
+ aria-hidden="true"
+ viewBox="0 0 16 16">
+ <title>Loading</title>
+ <g stroke-width="1"
+ fill="currentColor"
+ stroke="currentColor">
+ <path d="M.5,8a7.5,7.5,0,1,1,1.91,5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"/>
+ </g>
+ </svg>
+ </span>
+ {/if}
+ </button>
+ {/if}
+</template>
diff --git a/apps/web-shared/src/components/chip.svelte b/apps/web-shared/src/components/chip.svelte
new file mode 100644
index 0000000..7fbb445
--- /dev/null
+++ b/apps/web-shared/src/components/chip.svelte
@@ -0,0 +1,50 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {createEventDispatcher} from "svelte";
+ import Button from "./button.svelte";
+
+ const dispatch = createEventDispatcher();
+ export let removable = false;
+ export let clickable = false;
+ export let text = "";
+ export let id = "";
+ export let color = "";
+ export let tabindex = "";
+
+ function handle_remove() {
+ if (removable) {
+ dispatch("remove", {
+ id
+ });
+ }
+ }
+
+ function handle_click() {
+ if (clickable) {
+ dispatch("clicked", {
+ id
+ });
+ }
+ }
+</script>
+
+<div class="chip break-word text-sm justify-between justify-start@md {clickable ? 'chip--interactive' : ''}"
+ on:click={handle_click}
+ id={id}
+ style={color !== "" ? `background-color: ${color}15; border: 1px solid ${color}; color: ${color}` : ""}
+ {tabindex}
+>
+ <span class="chip__label">{text}</span>
+
+ {#if removable}
+ <Button class="chip__btn"
+ variant="reset"
+ style="{color !== '' ? `background-color: ${color}45;` : ''}"
+ {tabindex}
+ icon="{IconNames.x}"
+ icon_width="initial"
+ icon_height="initial"
+ on:click={handle_remove}
+ />
+ {/if}
+</div>
diff --git a/apps/web-shared/src/components/details.svelte b/apps/web-shared/src/components/details.svelte
new file mode 100644
index 0000000..6ccacb0
--- /dev/null
+++ b/apps/web-shared/src/components/details.svelte
@@ -0,0 +1,35 @@
+<script>
+ import {random_string} from "$shared/lib/helpers";
+
+ let open = false;
+ export let summary;
+ const id = "details-" + random_string(4);
+
+ function on_toggle(event) {
+ open = event.target.open;
+ }
+</script>
+
+<details class="details margin-bottom-sm"
+ on:toggle={on_toggle}
+ id={id}>
+ <summary class="details__summary"
+ aria-controls={id}
+ aria-expanded={open}>
+ <span class="flex items-center">
+ <svg
+ class="icon icon--xxs margin-right-xxxs"
+ aria-hidden="true"
+ viewBox="0 0 12 12">
+ <path
+ d="M2.783.088A.5.5,0,0,0,2,.5v11a.5.5,0,0,0,.268.442A.49.49,0,0,0,2.5,12a.5.5,0,0,0,.283-.088l8-5.5a.5.5,0,0,0,0-.824Z"/>
+ </svg>
+ <span>{summary}</span>
+ </span>
+ </summary>
+ <div
+ class="details__content text-component margin-top-xs"
+ aria-hidden={!open}>
+ <slot/>
+ </div>
+</details>
diff --git a/apps/web-shared/src/components/dropdown.svelte b/apps/web-shared/src/components/dropdown.svelte
new file mode 100644
index 0000000..b5068a7
--- /dev/null
+++ b/apps/web-shared/src/components/dropdown.svelte
@@ -0,0 +1,374 @@
+<script lang="ts">
+ // @ts-ignore
+ import {go, highlight} from "fuzzysort";
+ import {element_has_focus, random_string} from "$shared/lib/helpers";
+ import Button from "$shared/components/button.svelte";
+ import Chip from "$shared/components/chip.svelte";
+
+ export let name;
+ export let id;
+ export let maxlength;
+ export let placeholder = "Search";
+ export let entries = [];
+ export let createable = false;
+ export let loading = false;
+ export let multiple = false;
+ export let noResultsText;
+ export let errorText;
+ export let label;
+ export let on_create_async = ({name: string}) => {
+ };
+
+ export const reset = () => methods.reset();
+ export const select = (id: string) => methods.select_entry(id);
+ export const deselect = (id: string) => methods.deselect_entry(id);
+
+ const INTERNAL_ID = "__dropdown-" + random_string(5);
+
+ let entriesUlNode;
+ let searchInputNode;
+ let searchResults = [];
+ let searchValue = "";
+ let showCreationHint = false;
+ let showDropdown = false;
+ let lastKeydownCode = "";
+ let mouseIsOverDropdown = false;
+ let mouseIsOverComponent = false;
+
+ $: hasSelection = entries.some((c) => c.selected === true);
+ $: if (searchValue.trim()) {
+ showCreationHint = createable && entries.every((c) => search.normalise_value(c.name) !== search.normalise_value(searchValue));
+ } else {
+ showCreationHint = false;
+ entries = methods.get_sorted_array(entries);
+ }
+
+ const search = {
+ normalise_value(value: string): string {
+ if (!value) {
+ return "";
+ }
+ return value.toString().trim().toLowerCase();
+ },
+ do() {
+ const query = search.normalise_value(searchValue);
+ if (!query.trim()) {
+ searchResults = [];
+ return;
+ }
+
+ const options = {
+ limit: 10,
+ allowTypo: true,
+ threshold: -10000,
+ key: "name",
+ };
+ searchResults = go(query, entries, options);
+ showDropdown = true;
+ },
+ on_input_focusout() {
+ if (lastKeydownCode !== "Tab" && (mouseIsOverDropdown || lastKeydownCode === "ArrowDown")) {
+ return;
+ }
+ const selected = entries.find((c) => c.selected === true);
+ if (selected && !multiple) {
+ searchValue = selected.name;
+ }
+ showDropdown = false;
+ }
+ };
+
+ const methods = {
+ reset(focus_input = false) {
+ searchValue = "";
+ const copy = entries;
+ for (const entry of copy) {
+ entry.selected = false;
+ }
+ entries = methods.get_sorted_array(copy);
+ if (focus_input) {
+ searchInputNode?.focus();
+ showDropdown = true;
+ } else {
+ showDropdown = false;
+ }
+ },
+ async create_entry(name) {
+ if (!name || !createable || loading) {
+ console.log("Not sending creation event due to failed preconditions", {name, createable, loading});
+ return;
+ }
+ try {
+ await on_create_async({name});
+ searchValue = "";
+ loading = false;
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ select_entry(entry_id) {
+ if (!entry_id || loading) {
+ console.log("Not selecting entry due to failed preconditions", {
+ entry_id,
+ loading,
+ });
+ return;
+ }
+
+ const copy = entries;
+ let selected;
+ for (const entry of entries) {
+ if (entry.id === entry_id) {
+ entry.selected = true;
+ selected = entry;
+ if (multiple) {
+ searchValue = "";
+ } else {
+ searchValue = entry.name;
+ }
+ } else if (!multiple) {
+ entry.selected = false;
+ }
+ }
+ entries = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ searchResults = [];
+ },
+ deselect_entry(entry_id) {
+ if (!entry_id || loading) {
+ console.log("Not deselecting entry due to failed preconditions", {
+ entry_id,
+ loading,
+ });
+ return;
+ }
+ console.log("Deselecting entry", entry_id);
+
+ const copy = entries;
+ let deselected;
+
+ for (const entry of copy) {
+ if (entry.id === entry_id) {
+ entry.selected = false;
+ deselected = entry;
+ }
+ }
+
+ entries = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ },
+ get_sorted_array(entries: Array<DropdownEntry>): Array<DropdownEntry> {
+ if (!entries) {
+ return;
+ }
+ if (entries.length < 1) {
+ return [];
+ }
+ if (searchValue) {
+ return entries;
+ }
+ return (entries as any).sort((a, b) => {
+ search.normalise_value(a.name).localeCompare(search.normalise_value(b.name));
+ });
+ },
+ };
+
+ const windowEvents = {
+ on_mousemove(event) {
+ mouseIsOverDropdown = (event.target?.closest("#" + INTERNAL_ID + " .autocomplete__results") != null ?? false);
+ mouseIsOverComponent = (event.target?.closest("#" + INTERNAL_ID) != null ?? false);
+ },
+ on_click(event) {
+ if (showDropdown && !mouseIsOverDropdown && !mouseIsOverComponent && event.target.id !== id && event.target?.htmlFor !== id) {
+ showDropdown = false;
+ }
+ },
+ on_keydown(event) {
+ lastKeydownCode = event.code;
+ const enterPressed = event.code === "Enter";
+ const backspacePressed = event.code === "Backspace";
+ const arrowUpPressed = event.code === "ArrowUp";
+ const spacePressed = event.code === "Space";
+ const arrowDownPressed = event.code === "ArrowDown";
+ const searchInputHasFocus = element_has_focus(searchInputNode);
+ const focusedEntry = entriesUlNode?.querySelector("li:focus");
+
+ if (showDropdown && (enterPressed || arrowDownPressed)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (searchInputHasFocus && backspacePressed && !searchValue && entries.length > 0) {
+ if (entries.filter(c => c.selected === true).at(-1)?.id ?? false) {
+ methods.deselect_entry(entries.filter(c => c.selected === true).at(-1)?.id ?? "");
+ }
+ return;
+ }
+
+ if (searchInputHasFocus) {
+ if (enterPressed && showCreationHint) {
+ methods.create_entry(searchValue.trim());
+ return;
+ }
+
+ if (arrowDownPressed) {
+ const firstEntry = entriesUlNode.querySelector("li:first-of-type");
+ if (firstEntry) {
+ firstEntry.focus();
+ }
+ return;
+ }
+ }
+
+ if (focusedEntry && (arrowUpPressed || arrowDownPressed)) {
+ if (arrowDownPressed && focusedEntry.nextElementSibling) {
+ focusedEntry.nextElementSibling.focus();
+ } else if (arrowUpPressed && focusedEntry.previousElementSibling) {
+ focusedEntry.previousElementSibling.focus();
+ }
+ return;
+ }
+
+ if (focusedEntry && (spacePressed || enterPressed)) {
+ methods.select_entry(focusedEntry.dataset.id);
+ return;
+ }
+
+ if (lastKeydownCode === "Tab" && !searchInputHasFocus) {
+ showDropdown = false;
+ }
+ },
+ on_touchend(event) {
+ windowEvents.on_mousemove(event);
+ }
+ };
+
+ interface DropdownEntry {
+ name: string,
+ id: string,
+ }
+</script>
+
+<svelte:window
+ on:keydown={windowEvents.on_keydown}
+ on:mousemove={windowEvents.on_mousemove}
+ on:touchend={windowEvents.on_touchend}
+ on:click={windowEvents.on_click}
+/>
+
+{#if label}
+ <label for="{id}"
+ class="form-label margin-bottom-xxs">{label}</label>
+{/if}
+
+<div class="autocomplete position-relative select-auto"
+ class:cursor-wait={loading}
+ class:autocomplete--results-visible={showDropdown}
+ class:select-auto--selection-done={searchValue}
+ id={INTERNAL_ID}
+>
+ <!-- input -->
+ <div class="select-auto__input-wrapper form-control"
+ class:multiple={multiple === true}
+ class:has-selection={hasSelection}>
+ {#if multiple === true && hasSelection}
+ {#each entries.filter((c) => c.selected === true) as entry}
+ <Chip id={entry.id}
+ removable={true}
+ tabindex="-1"
+ on:remove={() => methods.deselect_entry(entry.id)}
+ text={entry.name}/>
+ {/each}
+ {/if}
+ <input
+ class="reset width-100%"
+ style="outline:none;"
+ type="text"
+ {name}
+ {id}
+ {maxlength}
+ {placeholder}
+ bind:value={searchValue}
+ bind:this={searchInputNode}
+ on:input={() => search.do()}
+ on:click={() => (showDropdown = true)}
+ on:focus={() => (showDropdown = true)}
+ on:blur={search.on_input_focusout}
+ autocomplete="off"
+ />
+ <div class="select-auto__input-icon-wrapper">
+ <!-- arrow icon -->
+ <svg class="icon"
+ viewBox="0 0 16 16">
+ <title>Open selection</title>
+ <polyline points="1 5 8 12 15 5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"/>
+ </svg>
+
+ <!-- close X icon -->
+ <button class="reset select-auto__input-btn"
+ type="button"
+ on:click={() => reset(true)}>
+ <svg class="icon"
+ viewBox="0 0 16 16">
+ <title>Reset selection</title>
+ <path
+ d="M8,0a8,8,0,1,0,8,8A8,8,0,0,0,8,0Zm3.707,10.293a1,1,0,1,1-1.414,1.414L8,9.414,5.707,11.707a1,1,0,0,1-1.414-1.414L6.586,8,4.293,5.707A1,1,0,0,1,5.707,4.293L8,6.586l2.293-2.293a1,1,0,1,1,1.414,1.414L9.414,8Z"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {#if errorText}
+ <small class="color-error">{errorText}</small>
+ {/if}
+
+ <!-- dropdown -->
+ <div class="autocomplete__results select-auto__results">
+ <ul bind:this={entriesUlNode}
+ on:keydown={(event) => event.code.startsWith("Arrow") && event.preventDefault()}
+ tabindex="-1"
+ class="autocomplete__list">
+ {#if searchResults.length > 0}
+ {#each searchResults.filter((c) => !c.selected) as result}
+ <li class="select-auto__option padding-y-xs padding-x-sm"
+ data-id={result.obj.id}
+ on:click={(e) => methods.select_entry(e.target.dataset.id)}
+ tabindex="-1">
+ {@html highlight(result, (open = '<span class="font-semibold">'), (close = "</span>"))}
+ </li>
+ {/each}
+ {:else if entries.length > 0}
+ {#each entries.filter((c) => !c.selected) as entry}
+ <li class="select-auto__option padding-y-xs padding-x-sm"
+ data-id={entry.id}
+ on:click={(e) => methods.select_entry(e.target.dataset.id)}
+ tabindex="-1">
+ {entry.name}
+ </li>
+ {/each}
+ {:else}
+ <li class="select-auto__option text-center padding-y-xs padding-x-sm pointer-events-none"
+ tabindex="-1">
+ {noResultsText}
+ </li>
+ {/if}
+ </ul>
+ {#if showCreationHint}
+ <div class="width-100% border-top border-bg-lighter padding-xxxs">
+ <Button variant="reset"
+ type="button"
+ class="width-100%"
+ text="Press enter or click to create {searchValue.trim()}"
+ title="Press enter or click here to create {searchValue.trim()}"
+ loading={loading}
+ on:click={() => methods.create_entry(searchValue.trim())}/>
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/form/index.ts b/apps/web-shared/src/components/form/index.ts
new file mode 100644
index 0000000..08769bd
--- /dev/null
+++ b/apps/web-shared/src/components/form/index.ts
@@ -0,0 +1,5 @@
+import Textarea from "./textarea.svelte";
+
+export {
+ Textarea
+};
diff --git a/apps/web-shared/src/components/form/textarea.svelte b/apps/web-shared/src/components/form/textarea.svelte
new file mode 100644
index 0000000..b313d2e
--- /dev/null
+++ b/apps/web-shared/src/components/form/textarea.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ export let id;
+ export let disabled = false;
+ export let loading = false;
+ export let rows = 2;
+ export let cols = 0;
+ export let name;
+ export let placeholder;
+ export let value;
+ export let label;
+ export let errorText;
+
+ $: shared_props = {
+ rows: rows || null,
+ cols: cols || null,
+ name: name || null,
+ id: id || null,
+ disabled: disabled || null,
+ class: [`form-control ${loading ? "c-disabled loading" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+
+ let textarea;
+ let scrollHeight = 0;
+
+ $:if (textarea) {
+ scrollHeight = textarea.scrollHeight;
+ }
+
+ function on_input(event) {
+ event.target.style.height = "auto";
+ event.target.style.height = (this.scrollHeight) + "px";
+ }
+</script>
+
+{#if label}
+ <label for="{id}"
+ class="form-label margin-bottom-xxs">{label}</label>
+{/if}
+<textarea {...shared_props}
+ {placeholder}
+ style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};"
+ bind:value={value}
+ bind:this={textarea}
+ on:input={on_input}
+></textarea>
+{#if errorText}
+ <small class="color-error">{errorText}</small>
+{/if}
diff --git a/apps/web-shared/src/components/icon.svelte b/apps/web-shared/src/components/icon.svelte
new file mode 100644
index 0000000..144b45d
--- /dev/null
+++ b/apps/web-shared/src/components/icon.svelte
@@ -0,0 +1,87 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+
+ const icons = [
+ {
+ box: 16,
+ name: IconNames.verticalDots,
+ svg: `<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.clock,
+ svg: `<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>`,
+ },
+ {
+ box: 21,
+ name: IconNames.trash,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 2)"><path d="m2.5 2.5h10v12c0 1.1045695-.8954305 2-2 2h-6c-1.1045695 0-2-.8954305-2-2zm5-2c1.0543618 0 1.91816512.81587779 1.99451426 1.85073766l.00548574.14926234h-4c0-1.1045695.8954305-2 2-2z"/><path d="m.5 2.5h14"/><path d="m5.5 5.5v8"/><path d="m9.5 5.5v8"/></g>`,
+ },
+ {
+ box: 21,
+ name: IconNames.pencilSquare,
+ svg: `
+ <g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m14 1c.8284271.82842712.8284271 2.17157288 0 3l-9.5 9.5-4 1 1-3.9436508 9.5038371-9.55252193c.7829896-.78700064 2.0312313-.82943964 2.864366-.12506788z"/><path d="m6.5 14.5h8"/><path d="m12.5 3.5 1 1"/></g>
+ `,
+ },
+ {
+ box: 16,
+ name: IconNames.x,
+ svg: `<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.funnel,
+ svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.funnelFilled,
+ svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.github,
+ svg: `
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
+ `
+ },
+ {
+ box: 21,
+ name: IconNames.refresh,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><path d="m4.5 1.5c-2.4138473 1.37729434-4 4.02194088-4 7 0 4.418278 3.581722 8 8 8s8-3.581722 8-8-3.581722-8-8-8"/><path d="m4.5 5.5v-4h-4"/></g> `
+ },
+ {
+ box: 21,
+ name: IconNames.resetHard,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="matrix(0 1 1 0 2.5 2.5)"><path d="m13 11 3 3v-6c0-3.36502327-2.0776-6.24479706-5.0200433-7.42656457-.9209869-.36989409-1.92670197-.57343543-2.9799567-.57343543-4.418278 0-8 3.581722-8 8s3.581722 8 8 8c1.48966767 0 3.4724708-.3698516 5.0913668-1.5380762" transform="matrix(-1 0 0 -1 16 16)"/><path d="m5 5 6 6"/><path d="m11 5-6 6"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.arrowUp,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 3)"><path d="m8.5 4.5-4-4-4.029 4"/><path d="m4.5.5v13"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.arrowDown,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 4)"><path d="m.5 9.499 4 4.001 4-4.001"/><path d="m4.5.5v13" transform="matrix(-1 0 0 -1 9 14)"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.chevronDown,
+ svg: `<path d="m8.5.5-4 4-4-4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 8)"/>`
+ }
+ ];
+
+ export let name;
+ export let fill = false;
+ export let width = "1rem";
+ export let height = "1rem";
+ const displayIcon = icons.find((e) => e.name === name);
+</script>
+
+<svg class="icon {$$restProps.class ?? ''}"
+ style="width: {width}; height:{height}; fill: currentColor;"
+ viewBox="0 0 {displayIcon.box} {displayIcon.box}">
+ {@html displayIcon.svg}
+</svg>
diff --git a/apps/web-shared/src/components/menu/index.ts b/apps/web-shared/src/components/menu/index.ts
new file mode 100644
index 0000000..8eb7938
--- /dev/null
+++ b/apps/web-shared/src/components/menu/index.ts
@@ -0,0 +1,9 @@
+import Menu from "./menu.svelte";
+import MenuItem from "./item.svelte";
+import MenuItemSeparator from "./separator.svelte";
+
+export {
+ Menu,
+ MenuItem,
+ MenuItemSeparator
+};
diff --git a/apps/web-shared/src/components/menu/item.svelte b/apps/web-shared/src/components/menu/item.svelte
new file mode 100644
index 0000000..aeb0f99
--- /dev/null
+++ b/apps/web-shared/src/components/menu/item.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+ export let danger = false;
+</script>
+<li role="menuitem" on:click>
+ <span class="menu__content {danger ? 'bg-error-lighter@hover color-white@hover' : ''}">
+ <slot/>
+ </span>
+</li>
diff --git a/apps/web-shared/src/components/menu/menu.svelte b/apps/web-shared/src/components/menu/menu.svelte
new file mode 100644
index 0000000..33b1160
--- /dev/null
+++ b/apps/web-shared/src/components/menu/menu.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+ import {random_string} from "$shared/lib/helpers";
+
+ export let id = "__menu_" + random_string(3);
+ export let trigger: HTMLElement;
+ export let show = false;
+
+ let windowInnerWidth = 0;
+ let windowInnerHeight = 0;
+ let menu: HTMLMenuElement;
+
+ $: if (show && menu && trigger) {
+ const
+ selectedTriggerPosition = trigger.getBoundingClientRect(),
+ menuOnTop = (windowInnerHeight - selectedTriggerPosition.bottom) < selectedTriggerPosition.top,
+ left = selectedTriggerPosition.left,
+ right = (windowInnerWidth - selectedTriggerPosition.right),
+ isRight = (windowInnerWidth < selectedTriggerPosition.left + menu.offsetWidth),
+ vertical = menuOnTop
+ ? "bottom: " + (windowInnerHeight - selectedTriggerPosition.top) + "px;"
+ : "top: " + selectedTriggerPosition.bottom + "px;";
+
+ let horizontal = isRight ? "right: " + right + "px;" : "left: " + left + "px;";
+
+ // check right position is correct -> otherwise set left to 0
+ if (isRight && (right + menu.offsetWidth) > windowInnerWidth) horizontal = ("left: " + (windowInnerWidth - menu.offsetWidth) / 2 + "px;");
+ const maxHeight = menuOnTop ? selectedTriggerPosition.top - 20 : windowInnerHeight - selectedTriggerPosition.bottom - 20;
+ menu.setAttribute("style", horizontal + vertical + "max-height:" + Math.floor(maxHeight) + "px;");
+ }
+
+ function on_window_click(event) {
+ if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false;
+ }
+
+ function on_window_touchend(event) {
+ if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false;
+ }
+</script>
+
+<svelte:window
+ on:click={on_window_click}
+ on:touchend={on_window_touchend}
+ bind:innerWidth={windowInnerWidth}
+ bind:innerHeight={windowInnerHeight}
+/>
+
+<menu class="menu"
+ id="{id}"
+ bind:this={menu}
+ class:menu--is-visible={show}
+ aria-expanded="{show}"
+ aria-haspopup="true">
+ <slot name="options"/>
+</menu>
diff --git a/apps/web-shared/src/components/menu/separator.svelte b/apps/web-shared/src/components/menu/separator.svelte
new file mode 100644
index 0000000..798dce0
--- /dev/null
+++ b/apps/web-shared/src/components/menu/separator.svelte
@@ -0,0 +1,2 @@
+<li class="menu__separator"
+ role="separator"></li>
diff --git a/apps/web-shared/src/components/modal.svelte b/apps/web-shared/src/components/modal.svelte
new file mode 100644
index 0000000..f3b633c
--- /dev/null
+++ b/apps/web-shared/src/components/modal.svelte
@@ -0,0 +1,66 @@
+<script>
+ import {random_string} from "$shared/lib/helpers";
+
+ export let title = "";
+ let isVisible = false;
+ const modal_id = "modal_" + random_string(5);
+
+ function handle_keyup(e) {
+ if (e.key === "Escape") {
+ isVisible = false;
+ }
+ }
+
+ export const functions = {
+ open() {
+ isVisible = true;
+ window.addEventListener("keyup", handle_keyup);
+ },
+ close() {
+ isVisible = false;
+ window.removeEventListener("keyup", handle_keyup);
+ },
+ };
+</script>
+
+<div class="modal modal--animate-scale flex flex-center padding-md bg-dark bg-opacity-40% {isVisible ? 'modal--is-visible' : ''}"
+ id={modal_id}
+>
+ <div class="modal__content width-100% max-width-xs max-height-100% overflow-auto radius-md shadow-md bg"
+ role="alertdialog"
+ >
+ <header class="padding-y-sm padding-x-md flex items-center justify-between"
+ >
+ <h4 class="text-truncate">{title}</h4>
+
+ <button class="reset modal__close-btn modal__close-btn--inner"
+ on:click={functions.close}
+ >
+ <svg class="icon"
+ viewBox="0 0 20 20">
+ <title>Close modal window</title>
+ <g fill="none"
+ stroke="currentColor"
+ stroke-miterlimit="10"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <line x1="3"
+ y1="3"
+ x2="17"
+ y2="17"/>
+ <line x1="17"
+ y1="3"
+ x2="3"
+ y2="17"/>
+ </g>
+ </svg>
+ </button>
+ </header>
+
+ <div class="padding-bottom-md padding-x-md">
+ <slot/>
+ </div>
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/pre-header.svelte b/apps/web-shared/src/components/pre-header.svelte
new file mode 100644
index 0000000..87a19b1
--- /dev/null
+++ b/apps/web-shared/src/components/pre-header.svelte
@@ -0,0 +1,37 @@
+<script>
+ export let closable = true;
+ export let show = false;
+</script>
+
+<div class="pre-header padding-y-xs" style="{show ? '' : 'display:none'}">
+ <div class="container max-width-lg position-relative">
+ <div class="text-component text-sm padding-right-lg">
+ <p>
+ <slot/>
+ </p>
+ </div>
+ {#if closable}
+ <button class="reset pre-header__close-btn"
+ on:click={(event) => event.target.closest(".pre-header")?.remove()}>
+ <svg class="icon"
+ viewBox="0 0 20 20">
+ <title>Close header banner</title>
+ <g fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2">
+ <line x1="4"
+ y1="4"
+ x2="16"
+ y2="16"/>
+ <line x1="16"
+ y1="4"
+ x2="4"
+ y2="16"/>
+ </g>
+ </svg>
+ </button>
+ {/if}
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/stopwatch.svelte b/apps/web-shared/src/components/stopwatch.svelte
new file mode 100644
index 0000000..8287e31
--- /dev/null
+++ b/apps/web-shared/src/components/stopwatch.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import {writable_persistent} from "$shared/lib/persistent-store";
+ import Button from "$shared/components/button.svelte";
+ import {Textarea} from "$shared/components/form";
+ import {StorageKeys} from "$shared/lib/configuration";
+ import {Temporal} from "@js-temporal/polyfill";
+ import {createEventDispatcher, onMount} from "svelte";
+
+ const state = writable_persistent({
+ initialState: {
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ startTime: null as Temporal.PlainTime,
+ isRunning: false,
+ intervalId: 0,
+ note: "",
+ },
+ name: StorageKeys.stopwatch,
+ });
+
+ let timeString;
+
+ $: if ($state.hours || $state.minutes || $state.seconds) {
+ timeString = $state.hours.toLocaleString(undefined, {minimumIntegerDigits: 2})
+ + ":" + $state.minutes.toLocaleString(undefined, {minimumIntegerDigits: 2})
+ + ":" + $state.seconds.toLocaleString(undefined, {minimumIntegerDigits: 2});
+ } else {
+ timeString = "--:--:--";
+ }
+
+ onMount(() => {
+ if ($state.isRunning) {
+ clearInterval($state.intervalId);
+ $state.intervalId = setInterval(step, 1000);
+ }
+ });
+
+ const dispatch = createEventDispatcher();
+
+ function step() {
+ $state.seconds = $state.seconds + 1;
+
+ if ($state.seconds == 60) {
+ $state.minutes = $state.minutes + 1;
+ $state.seconds = 0;
+ }
+
+ if ($state.minutes == 60) {
+ $state.hours = $state.hours + 1;
+ $state.minutes = 0;
+ $state.seconds = 0;
+ }
+
+ if (!$state.startTime) $state.startTime = Temporal.Now.plainTimeISO();
+ }
+
+ function reset() {
+ clearInterval($state.intervalId);
+ $state.isRunning = false;
+ $state.hours = 0;
+ $state.minutes = 0;
+ $state.seconds = 0;
+ $state.startTime = null;
+ $state.intervalId = 0;
+ $state.note = "";
+ }
+
+ let roundUpToNearest = 30;
+ let roundDownToNearest = 30;
+
+ function on_round_up() {
+ const newTime = Temporal.PlainTime
+ .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds})
+ .round({
+ roundingIncrement: roundUpToNearest,
+ smallestUnit: "minute",
+ roundingMode: "ceil"
+ });
+ $state.hours = newTime.hour;
+ $state.minutes = newTime.minute;
+ $state.seconds = newTime.second;
+ }
+
+ function on_round_down() {
+ const newTime = Temporal.PlainTime
+ .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds,})
+ .round({
+ roundingIncrement: roundDownToNearest,
+ smallestUnit: "minute",
+ roundingMode: "trunc"
+ });
+ $state.hours = newTime.hour;
+ $state.minutes = newTime.minute;
+ $state.seconds = newTime.second;
+ }
+
+ function on_start_stop() {
+ if ($state.isRunning) {
+ clearInterval($state.intervalId);
+ $state.isRunning = false;
+ return;
+ }
+ step();
+ $state.intervalId = setInterval(step, 1000);
+ $state.isRunning = true;
+ }
+
+ function on_create_entry() {
+ if (!$state.startTime) return;
+ const plainStartTime = Temporal.PlainTime.from($state.startTime);
+ dispatch("create", {
+ from: plainStartTime,
+ to: plainStartTime.add({hours: $state.hours, minutes: $state.minutes, seconds: $state.seconds}),
+ description: $state.note
+ });
+ reset();
+ }
+</script>
+
+<div class="grid">
+ <div class="col-6">
+ <slot name="header"></slot>
+ <pre class="text-xxl padding-y-sm">{timeString}</pre>
+ </div>
+ <div class="col-6 flex align-bottom flex-column text-xs">
+ <Button title="{$state.isRunning ? 'Stop' : 'Start'}"
+ text="{$state.isRunning ? 'Stop' : 'Start'}"
+ variant="link"
+ on:click={on_start_stop}/>
+
+ {#if $state.startTime}
+ <Button title="Reset"
+ text="Reset"
+ variant="link"
+ class="bg-error-lighter@hover color-white@hover"
+ on:click={reset}/>
+ {#if !$state.isRunning}
+ <Button title="Round up"
+ text="Round up"
+ variant="link"
+ on:click={on_round_up}/>
+ <Button title="Round down"
+ text="Round down"
+ variant="link"
+ on:click={on_round_down}/>
+ {#if $state.minutes > 0 || $state.hours > 0}
+ <Button title="Create entry"
+ text="Create entry"
+ variant="link"
+ on:click={on_create_entry}/>
+ {/if}
+ {/if}
+ {/if}
+ </div>
+</div>
+<Textarea class="width-100% margin-top-xs"
+ placeholder="What's your focus?"
+ rows="1"
+ bind:value={$state.note}
+/>
diff --git a/apps/web-shared/src/components/table/index.ts b/apps/web-shared/src/components/table/index.ts
new file mode 100644
index 0000000..8390c0e
--- /dev/null
+++ b/apps/web-shared/src/components/table/index.ts
@@ -0,0 +1,15 @@
+import TablePaginator from "./paginator.svelte";
+import Table from "./table.svelte";
+import THead from "./thead.svelte";
+import TBody from "./tbody.svelte";
+import TCell from "./tcell.svelte";
+import TRow from "./trow.svelte";
+
+export {
+ TablePaginator,
+ Table,
+ THead,
+ TBody,
+ TCell,
+ TRow
+};
diff --git a/apps/web-shared/src/components/table/paginator.svelte b/apps/web-shared/src/components/table/paginator.svelte
new file mode 100644
index 0000000..53c6392
--- /dev/null
+++ b/apps/web-shared/src/components/table/paginator.svelte
@@ -0,0 +1,101 @@
+<script>
+ import {createEventDispatcher, onMount} from "svelte";
+ import {restrict_input_to_numbers} from "$shared/lib/helpers";
+
+ const dispatch = createEventDispatcher();
+ export let page = 1;
+ export let pageCount = 1;
+ let prevCount = page;
+ let canIncrement = false;
+ let canDecrement = false;
+ $: canIncrement = page < pageCount;
+ $: canDecrement = page > 1;
+
+ onMount(() => {
+ restrict_input_to_numbers(document.querySelector("#curr-page"));
+ });
+
+ function increment() {
+ if (canIncrement) {
+ page++;
+ }
+ }
+
+ function decrement() {
+ if (canDecrement) {
+ page--;
+ }
+ }
+
+ $: if (page) {
+ handle_change();
+ }
+
+ function handle_change() {
+ if (page === prevCount) {
+ return;
+ }
+ prevCount = page;
+ if (page > pageCount) {
+ page = pageCount;
+ }
+ dispatch("value_change", {
+ newValue: page,
+ });
+ }
+</script>
+
+<nav class="pagination"
+ aria-label="Pagination">
+ <ul class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md">
+ <li>
+ <button on:click={decrement}
+ class="reset pagination__item {canDecrement ? '' : 'c-disabled'}">
+ <svg class="icon icon--xs flip-x"
+ viewBox="0 0 16 16"
+ ><title>Go to previous page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+
+ <li>
+ <span class="pagination__jumper flex items-center">
+ <input
+ aria-label="Page number"
+ class="form-control"
+ id="curr-page"
+ type="text"
+ on:change={handle_change}
+ value={page}
+ />
+ <em>of {pageCount}</em>
+ </span>
+ </li>
+
+ <li>
+ <button on:click={increment}
+ class="reset pagination__item {canIncrement ? '' : 'c-disabled'}">
+ <svg class="icon icon--xs"
+ viewBox="0 0 16 16"
+ ><title>Go to next page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+ </ul>
+</nav>
diff --git a/apps/web-shared/src/components/table/table.svelte b/apps/web-shared/src/components/table/table.svelte
new file mode 100644
index 0000000..4acbf37
--- /dev/null
+++ b/apps/web-shared/src/components/table/table.svelte
@@ -0,0 +1,3 @@
+<table class="int-table {$$restProps.class ?? ''}">
+ <slot/>
+</table>
diff --git a/apps/web-shared/src/components/table/tbody.svelte b/apps/web-shared/src/components/table/tbody.svelte
new file mode 100644
index 0000000..f0617fa
--- /dev/null
+++ b/apps/web-shared/src/components/table/tbody.svelte
@@ -0,0 +1,3 @@
+<tbody class="int-table__body {$$restProps.class ?? ''}">
+<slot/>
+</tbody>
diff --git a/apps/web-shared/src/components/table/tcell.svelte b/apps/web-shared/src/components/table/tcell.svelte
new file mode 100644
index 0000000..76f500f
--- /dev/null
+++ b/apps/web-shared/src/components/table/tcell.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ export let thScope: "row"|"col"|"rowgroup"|"colgroup"|"";
+ export let colspan = "";
+ export let type: "th"|"td" = "td";
+ export let style;
+
+ $: shared_props = {
+ colspan: colspan || null,
+ style: style || null,
+ class: [type === "th" ? "int-table__cell--th" : "", "int-table__cell", $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+</script>
+{#if type === "th"}
+ <th {thScope}
+ {...shared_props}>
+ <slot/>
+ </th>
+{/if}
+{#if type === "td"}
+ <td {...shared_props}>
+ <slot/>
+ </td>
+{/if}
diff --git a/apps/web-shared/src/components/table/thead.svelte b/apps/web-shared/src/components/table/thead.svelte
new file mode 100644
index 0000000..aa20bf0
--- /dev/null
+++ b/apps/web-shared/src/components/table/thead.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import TRow from "./trow.svelte";
+</script>
+
+
+<thead class="int-table__header {$$restProps.class ?? ''}">
+<TRow>
+ <slot/>
+</TRow>
+</thead>
diff --git a/apps/web-shared/src/components/table/trow.svelte b/apps/web-shared/src/components/table/trow.svelte
new file mode 100644
index 0000000..35b34bb
--- /dev/null
+++ b/apps/web-shared/src/components/table/trow.svelte
@@ -0,0 +1,6 @@
+<script>
+ export let dataId;
+</script>
+<tr class="int-table__row {$$restProps.class ?? ''}" data-id={dataId}>
+ <slot/>
+</tr>
diff --git a/apps/web-shared/src/components/tile.svelte b/apps/web-shared/src/components/tile.svelte
new file mode 100644
index 0000000..b8e9cdf
--- /dev/null
+++ b/apps/web-shared/src/components/tile.svelte
@@ -0,0 +1,4 @@
+<section class="bg-light radius-sm padding-sm inner-glow shadow-xs {$$restProps.class??''}"
+ style="height: fit-content;">
+ <slot/>
+</section>