aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'code/app/src/components')
-rw-r--r--code/app/src/components/alert.svelte268
-rw-r--r--code/app/src/components/badge.svelte76
-rw-r--r--code/app/src/components/button.svelte116
-rw-r--r--code/app/src/components/checkbox.svelte29
-rw-r--r--code/app/src/components/combobox.svelte451
-rw-r--r--code/app/src/components/icons/adjustments.svelte14
-rw-r--r--code/app/src/components/icons/bars-3-center-left.svelte15
-rw-r--r--code/app/src/components/icons/calendar.svelte14
-rw-r--r--code/app/src/components/icons/check-circle.svelte13
-rw-r--r--code/app/src/components/icons/chevron-down.svelte7
-rw-r--r--code/app/src/components/icons/chevron-up-down.svelte13
-rw-r--r--code/app/src/components/icons/chevron-up.svelte7
-rw-r--r--code/app/src/components/icons/database.svelte14
-rw-r--r--code/app/src/components/icons/exclamation-circle.svelte13
-rw-r--r--code/app/src/components/icons/exclamation-triangle.svelte13
-rw-r--r--code/app/src/components/icons/folder-open.svelte14
-rw-r--r--code/app/src/components/icons/funnel.svelte7
-rw-r--r--code/app/src/components/icons/home.svelte14
-rw-r--r--code/app/src/components/icons/index.ts47
-rw-r--r--code/app/src/components/icons/information-circle.svelte13
-rw-r--r--code/app/src/components/icons/magnifying-glass.svelte13
-rw-r--r--code/app/src/components/icons/megaphone.svelte14
-rw-r--r--code/app/src/components/icons/menu.svelte14
-rw-r--r--code/app/src/components/icons/queue-list.svelte14
-rw-r--r--code/app/src/components/icons/spinner.svelte20
-rw-r--r--code/app/src/components/icons/x-circle.svelte13
-rw-r--r--code/app/src/components/icons/x-mark.svelte11
-rw-r--r--code/app/src/components/icons/x.svelte14
-rw-r--r--code/app/src/components/index.ts25
-rw-r--r--code/app/src/components/input.svelte112
-rw-r--r--code/app/src/components/locale-switcher.svelte56
-rw-r--r--code/app/src/components/notification.svelte119
-rw-r--r--code/app/src/components/project-status-badge.svelte25
-rw-r--r--code/app/src/components/switch.svelte125
-rw-r--r--code/app/src/components/textarea.svelte81
35 files changed, 1814 insertions, 0 deletions
diff --git a/code/app/src/components/alert.svelte b/code/app/src/components/alert.svelte
new file mode 100644
index 0000000..16d8340
--- /dev/null
+++ b/code/app/src/components/alert.svelte
@@ -0,0 +1,268 @@
+<script lang="ts">
+ import {random_string} from "$utilities/misc-helpers";
+ import {createEventDispatcher} from "svelte";
+ import {onMount} from "svelte";
+ import pwKey from "$actions/pwKey";
+ import {Temporal} from "temporal-polyfill";
+ import {ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon, XCircleIcon, XMarkIcon} from "./icons";
+
+ const dispatch = createEventDispatcher();
+ const noCooldownSetting = "no-cooldown";
+
+ let iconComponent: any;
+ let colorClassPart = "";
+
+ /**
+ * An optional id for this alert, a default is set if not specified.
+ * This value is necessary for closeable cooldown to work.
+ */
+ // if no unique id is supplied, cooldown will not work between page loads.
+ // Therefore we are disabling it with noCooldownSetting in the fallback id.
+ export let id = "alert--" + noCooldownSetting + "--" + random_string(4);
+ /**
+ * The title to communicate, value is optional
+ */
+ export let title = "";
+ /**
+ * The message to communicate, value is optional
+ */
+ export let message = "";
+ /**
+ * Changes the alerts color and icon.
+ */
+ export let type: "info" | "success" | "warning" | "error" = "info";
+ /**
+ * If true the alert can be removed from the DOM by clicking on a X icon on the upper right hand courner
+ */
+ export let closeable = false;
+ /**
+ * The amount of seconds that should go by before this alert is shown again, only works when a unique id is set.
+ * Set to ~ if it should only be shown once per client (State stored in localestorage).
+ **/
+ export let closeableCooldown = "-1";
+ /**
+ * The text that is displayed on the right link
+ */
+ export let rightLinkText = "";
+ /**
+ * An array of list items displayed under the message or title
+ */
+ export let listItems: Array<string> = [];
+ /**
+ * An array of {id:string;text:string;color?:string}, where id is dispatched back as an svelte event with this syntax act$id (ex: on:actcancel).
+ * Text is the button text
+ * Color is the optional tailwind color to used, the value is used in classes like bg-$color-50.
+ */
+ export let actions: Array<{ id: string; text: string; color?: string }> = [];
+ /**
+ * This value is set on a plain anchor tag without any svelte routing,
+ * listen to the on:rightLinkClick if you want to intercept the click without navigating
+ */
+ export let rightLinkHref = "javascript:void(0)";
+ $: cooldownEnabled =
+ id.indexOf(noCooldownSetting) === -1 && closeable && (closeableCooldown === "~" || parseInt(closeableCooldown) > 0);
+ /**
+ * Sets this alerts visibility state, when this is false it is removed from the dom using an {#if} block.
+ */
+ export let visible = closeableCooldown === "~" || parseInt(closeableCooldown) > 0 ? false : true;
+
+ export let _pwKey: string | undefined = undefined;
+
+ const cooldownStorageKey = "lastseen--" + id;
+
+ $: switch (type) {
+ case "info": {
+ colorClassPart = "blue";
+ iconComponent = InformationCircleIcon;
+ break;
+ }
+ case "warning": {
+ colorClassPart = "yellow";
+ iconComponent = ExclamationTriangleIcon;
+ break;
+ }
+ case "error": {
+ colorClassPart = "red";
+ iconComponent = XCircleIcon;
+ break;
+ }
+ case "success": {
+ colorClassPart = "green";
+ iconComponent = CheckCircleIcon;
+ break;
+ }
+ }
+
+ function close() {
+ visible = false;
+ if (cooldownEnabled) {
+ console.log("Cooldown enabled for " + id + ", " + closeableCooldown === "~" ? "with an endless cooldown" : "");
+ localStorage.setItem(cooldownStorageKey, String(Temporal.Now.instant().epochSeconds));
+ }
+ }
+
+ function rightLinkClicked() {
+ dispatch("rightLinkCliked");
+ }
+
+ function actionClicked(name: string) {
+ dispatch("act" + name);
+ }
+
+ // Manages the state of the alert if cooldown is enabled
+ function run_cooldown() {
+ if (!cooldownEnabled) {
+ console.log("Alert cooldown is not enabled for " + id);
+ return;
+ }
+ if (!localStorage.getItem(cooldownStorageKey)) {
+ console.log("Alert " + id + " has not been seen yet, displaying");
+ visible = true;
+ return;
+ }
+ // if (!visible) {
+ // console.log(
+ // "Alert " + id + " is not visible, stopping cooldown change"
+ // );
+ // return;
+ // }
+ if (closeableCooldown === "~") {
+ console.log("Alert " + id + " has an infinite cooldown, hiding");
+ visible = false;
+ return;
+ }
+
+ const lastSeen = Temporal.Instant.fromEpochSeconds(parseInt(localStorage.getItem(cooldownStorageKey) ?? "-1"));
+ if (Temporal.Instant.compare(Temporal.Now.instant(), lastSeen.add({seconds: parseInt(closeableCooldown)})) === 1) {
+ console.log(
+ "Alert " +
+ id +
+ " has a cooldown of " +
+ closeableCooldown +
+ " and was last seen " +
+ lastSeen.toLocaleString() +
+ " making it due for a showing",
+ );
+ visible = true;
+ } else {
+ visible = false;
+ }
+ }
+
+ onMount(() => {
+ if (cooldownEnabled) {
+ run_cooldown();
+ }
+
+ if (closeable && closeableCooldown && id.indexOf(noCooldownSetting) !== -1) {
+ // TODO: This prints twice before shutting up as it should, in this example look at the only alert with closeableCooldown in alertsbook.
+ // Looks like svelte mounts three times and that my id is only set on the third. Not sure it does at all after logging the id onMount.
+ console.error("Alert cooldown does not work without specifying a unique id, related id: " + id);
+ }
+ });
+</script>
+
+{#if visible}
+ <div class="rounded-md bg-{colorClassPart}-50 p-4 {$$restProps.class ?? ''}" use:pwKey={_pwKey}>
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svelte:component this={iconComponent} class="text-{colorClassPart}-400"/>
+ </div>
+ <div class="ml-3 text-sm w-full">
+ {#if !rightLinkText}
+ {#if title}
+ <h3 class="font-bold text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ {:else}
+ <div class="flex-1 md:flex md:justify-between">
+ <div>
+ {#if title}
+ <h3 class="font-medium text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ <p class="mt-3 text-sm md:mt-0 md:ml-6 flex items-end">
+ <a
+ href={rightLinkHref}
+ on:click={() => rightLinkClicked()}
+ class="whitespace-nowrap font-medium text-{colorClassPart}-700 hover:text-{colorClassPart}-600"
+ >
+ {rightLinkText}
+ <span aria-hidden="true"> &rarr;</span>
+ </a>
+ </p>
+ </div>
+ {/if}
+ {#if actions?.length ?? 0}
+ <div class="ml-2 mt-4">
+ <div class="-mx-2 -my-1.5 flex gap-1">
+ {#each actions as action}
+ {@const color = action?.color ?? colorClassPart}
+ <button
+ type="button"
+ on:click={() => actionClicked(action.id)}
+ class="rounded-md
+ bg-{color}-50
+ px-2 py-1.5 text-sm font-medium
+ text-{color}-800
+ hover:bg-{color}-100
+ focus:outline-none focus:ring-2
+ focus:ring-{color}-600
+ focus:ring-offset-2
+ focus:ring-offset-{color}-50"
+ >
+ {action.text}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <div class="ml-auto pl-3">
+ <div class="-mx-1.5 -my-1.5">
+ <button
+ type="button"
+ on:click={() => close()}
+ class="inline-flex rounded-md bg-{colorClassPart}-50 p-1.5 text-{colorClassPart}-500 hover:bg-{colorClassPart}-100 focus:outline-none focus:ring-2 focus:ring-{colorClassPart}-600 focus:ring-offset-2 focus:ring-offset-{colorClassPart}-50"
+ >
+ <span class="sr-only">Dismiss</span>
+ <XMarkIcon/>
+ </button>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+{/if}
diff --git a/code/app/src/components/badge.svelte b/code/app/src/components/badge.svelte
new file mode 100644
index 0000000..f967c2d
--- /dev/null
+++ b/code/app/src/components/badge.svelte
@@ -0,0 +1,76 @@
+<script lang="ts">
+ import { createEventDispatcher } from "svelte";
+
+ export let id: string | undefined = undefined;
+ export let type: "default" | "red" | "yellow" | "green" | "blue" | "tame" = "default";
+ export let text: string;
+ export let size: "large" | "default" = "default";
+ export let withDot: boolean = false;
+ export let removable: boolean = false;
+ export let uppercase: boolean = false;
+ export let tabindex: string | undefined = undefined;
+
+ let colorName = "gray";
+ let sizeClass = "rounded px-2 py-0.5 text-xs";
+ let dotSizeClass = "mr-1.5 h-2 w-2";
+
+ const dispatch = createEventDispatcher();
+
+ function handle_remove(event) {
+ dispatch("remove", { event, id, text });
+ }
+
+ $: switch (type) {
+ case "red":
+ colorName = "red";
+ break;
+ case "yellow":
+ colorName = "yellow";
+ break;
+ case "blue":
+ colorName = "blue";
+ break;
+ case "green":
+ colorName = "teal";
+ break;
+ case "default":
+ case "tame":
+ default:
+ colorName = "gray";
+ break;
+ }
+
+ $: switch (size) {
+ case "large":
+ sizeClass = "rounded-md px-2.5 py-0.5 text-sm";
+ dotSizeClass = "-ml-0.5 mr-1.5 h-2 w-2";
+ break;
+ case "default":
+ default:
+ sizeClass = "rounded px-2 py-0.5 text-xs";
+ dotSizeClass = "mr-1.5 h-2 w-2";
+ break;
+ }
+</script>
+
+<span class="inline-flex items-center font-medium {uppercase ? 'uppercase' : ''} bg-{colorName}-100 text-{colorName}-800 {sizeClass}" {id}>
+ {#if withDot}
+ <svg class="{dotSizeClass} text-{colorName}-400" fill="currentColor" viewBox="0 0 8 8">
+ <circle cx="4" cy="4" r="3" />
+ </svg>
+ {/if}
+ {text}
+ {#if removable}
+ <button
+ on:click={handle_remove}
+ tabindex={parseInt(tabindex)}
+ type="button"
+ class="ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-{colorName}-400 hover:bg-{colorName}-200 hover:text-{colorName}-500 focus:bg-{colorName}-500 focus:outline-none"
+ >
+ <span class="sr-only">Remove badge</span>
+ <svg class="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8">
+ <path stroke-linecap="round" stroke-width="1.5" d="M1 1l6 6m0-6L1 7" />
+ </svg>
+ </button>
+ {/if}
+</span>
diff --git a/code/app/src/components/button.svelte b/code/app/src/components/button.svelte
new file mode 100644
index 0000000..1d6ac4b
--- /dev/null
+++ b/code/app/src/components/button.svelte
@@ -0,0 +1,116 @@
+<script context="module" lang="ts">
+ export type ButtonKind = "primary" | "secondary" | "white" | "reset";
+ export type ButtonSize = "sm" | "lg" | "md" | "xl";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { SpinnerIcon } from "./icons";
+
+ export let kind = "primary" as ButtonKind;
+ export let size = "md" as ButtonSize;
+ export let type: "button" | "submit" | "reset" = "button";
+ export let id: string | undefined = undefined;
+ export let tabindex: string | undefined = undefined;
+ export let style: string | undefined = undefined;
+ export let title: string | undefined = undefined;
+ export let disabled: boolean | null = false;
+ export let href: string | undefined = undefined;
+ export let text: string;
+ export let loading = false;
+ export let fullWidth = false;
+ export let _pwKey: string | undefined = undefined;
+
+ let sizeClasses = "";
+ let kindClasses = "";
+ let spinnerTextClasses = "";
+ let spinnerMarginClasses = "";
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || loading || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ } as any;
+
+ $: switch (size) {
+ case "sm":
+ sizeClasses = "px-2.5 py-1.5 text-xs";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "md":
+ sizeClasses = "px-3 py-2 text-sm";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "lg":
+ sizeClasses = "px-3 py-2 text-lg";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "xl":
+ sizeClasses = "px-6 py-3 text-xl";
+ spinnerMarginClasses = "mr-2";
+ break;
+ }
+
+ $: switch (kind) {
+ case "secondary":
+ kindClasses = "border-transparent text-teal-800 bg-teal-100 hover:bg-teal-200 focus:ring-teal-500";
+ spinnerTextClasses = "teal-800";
+ break;
+ case "primary":
+ kindClasses = "border-transparent text-teal-900 bg-teal-300 hover:bg-teal-400 focus:ring-teal-500";
+ spinnerTextClasses = "text-teal-900";
+ break;
+ case "white":
+ kindClasses = "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-gray-400";
+ spinnerTextClasses = "text-gray-700";
+ break;
+ case "reset":
+ kindClasses = "reset outline-none ring-0 focus:ring-0 focus-visible:ring-0";
+ break;
+ }
+</script>
+
+{#if href}
+ <a
+ use:pwKey={_pwKey}
+ {...shared_props}
+ {href}
+ class="{sizeClasses} {kindClasses} {loading ? 'disabled:' : ''} {$$restProps.class ?? ''} {fullWidth
+ ? 'w-full justify-center'
+ : ''} disabled:cursor-not-allowed inline-flex items-center border font-bold rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </a>
+{:else}
+ <button
+ use:pwKey={_pwKey}
+ {...shared_props}
+ on:click
+ class="btn {sizeClasses} {kindClasses} {$$restProps.class ?? ''}
+ {fullWidth
+ ? 'w-full justify-center'
+ : ''} inline-flex items-center border font-bold rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </button>
+{/if}
+
+<style>
+ .reset {
+ border: 0px;
+ outline: none;
+ }
+
+ .reset:focus {
+ outline: none;
+ }
+</style>
diff --git a/code/app/src/components/checkbox.svelte b/code/app/src/components/checkbox.svelte
new file mode 100644
index 0000000..db72bee
--- /dev/null
+++ b/code/app/src/components/checkbox.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import {random_string} from "$utilities/misc-helpers";
+
+ export let label: string;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let disabled: boolean | null = null;
+ export let checked: boolean;
+ export let required: boolean | null = null;
+ export let _pwKey: string | undefined = undefined;
+</script>
+
+<div class="flex items-center">
+ <input
+ {name}
+ use:pwKey={_pwKey}
+ {disabled}
+ {id}
+ {required}
+ type="checkbox"
+ bind:checked
+ class="h-4 w-4 text-teal-600 focus:ring-teal-500 border-gray-300 rounded"
+ />
+ <label for={id} class="ml-2 block text-sm text-gray-900">
+ {@html required ? "<span class='text-red-500'>*</span>" : ""}
+ {label}
+ </label>
+</div>
diff --git a/code/app/src/components/combobox.svelte b/code/app/src/components/combobox.svelte
new file mode 100644
index 0000000..396c18a
--- /dev/null
+++ b/code/app/src/components/combobox.svelte
@@ -0,0 +1,451 @@
+<script lang="ts" context="module">
+ export type ComboboxOption = {
+ id: string;
+ name: string;
+ selected?: boolean;
+ };
+</script>
+
+<script lang="ts">
+ import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "./icons";
+ import { random_string } from "$utilities/misc-helpers";
+ import { go, highlight } from "fuzzysort";
+ import Badge from "./badge.svelte";
+ import Button from "./button.svelte";
+ import LL from "$i18n/i18n-svelte";
+ import { element_has_focus } from "$utilities/dom-helpers";
+
+ export let id = "combobox-" + random_string(3);
+ export let label: string | undefined = undefined;
+ export let errorText: string | undefined = undefined;
+ export let disabled: boolean | undefined = undefined;
+ export let required: boolean | undefined = undefined;
+ export let maxlength: number | undefined = undefined;
+ export let placeholder: string = $LL.combobox.search();
+ export let options: Array<ComboboxOption> | undefined = [];
+ export let createable = false;
+ export let loading = false;
+ export let multiple = false;
+ export let noResultsText: string = $LL.combobox.noRecordsFound();
+ export let on_create_async = 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 = "INTERNAL__" + id;
+
+ let optionsListId = id + "--options";
+ let searchInputNode;
+ let searchResults: Array<any> = [];
+ let searchValue = "";
+ let showCreationHint = false;
+ let showDropdown = false;
+ let inputHasFocus = false;
+ let lastKeydownCode = "";
+ let mouseIsOverDropdown = false;
+ let mouseIsOverComponent = false;
+
+ $: ariaErrorDescribedBy = id + "__" + "error";
+ $: colorName = errorText ? "red" : "teal";
+ $: attributes = {
+ "aria-describedby": errorText ? ariaErrorDescribedBy : null,
+ "aria-invalid": errorText ? "true" : null,
+ disabled: disabled || null,
+ required: required || null,
+ maxlength: maxlength || null,
+ id: id || null,
+ placeholder: placeholder || null,
+ } as any;
+ $: hasSelection = options.some((c) => c.selected === true);
+ $: if (searchValue.trim()) {
+ showCreationHint = createable && options.every((c) => search.normalise_value(c.name) !== search.normalise_value(searchValue));
+ } else {
+ showCreationHint = false;
+ options = methods.get_sorted_array(options);
+ }
+
+ function on_select(event) {
+ const node = event.target.closest("[data-id]");
+ if (!node) return;
+ methods.select_entry(node.dataset.id);
+ }
+
+ const search = {
+ normalise_value(value: string): string {
+ if (!value) {
+ return "";
+ }
+ return value.trim().toLowerCase();
+ },
+ do() {
+ const query = search.normalise_value(searchValue);
+
+ if (!query.trim()) {
+ searchResults = [];
+ return;
+ }
+
+ // @ts-ignore
+ searchResults = go(query, options, {
+ limit: 15,
+ allowTypo: true,
+ threshold: -10000,
+ key: "name",
+ });
+ showDropdown = true;
+ },
+ on_input_focus() {
+ showDropdown = true;
+ inputHasFocus = true;
+ },
+ on_input_click() {
+ showDropdown = true;
+ inputHasFocus = true;
+ },
+ on_input_focusout() {
+ inputHasFocus = false;
+ if (lastKeydownCode !== "Tab" && (mouseIsOverDropdown || lastKeydownCode === "ArrowDown")) {
+ return;
+ }
+ const selected = options.find((c) => c.selected === true);
+ if (selected && !multiple) {
+ searchValue = selected.name;
+ }
+ document.querySelector("#" + INTERNAL_ID + " ul li.focus")?.classList.remove("focus");
+ showDropdown = false;
+ },
+ on_input_wrapper_focus(event) {
+ if (event.code && event.code !== "Space" && event.code !== "Enter") return;
+ if (!element_has_focus(searchInputNode)) searchInputNode.focus();
+ showDropdown = true;
+ },
+ };
+
+ const methods = {
+ reset(focus_input = false) {
+ searchValue = "";
+ const copy = options;
+ for (const entry of copy) {
+ entry.selected = false;
+ }
+ options = methods.get_sorted_array(copy);
+ if (focus_input) {
+ searchInputNode?.focus();
+ showDropdown = true;
+ } else {
+ showDropdown = false;
+ }
+ },
+ async create_entry(name: string) {
+ 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(entryId: string) {
+ if (!entryId || loading) {
+ console.log("Not selecting entry due to failed preconditions", {
+ entryId,
+ loading,
+ });
+ return;
+ }
+
+ const copy = options;
+ for (const entry of options) {
+ if (entry.id === entryId) {
+ entry.selected = true;
+ if (multiple) {
+ searchValue = "";
+ } else {
+ searchValue = entry.name;
+ }
+ } else if (!multiple) {
+ entry.selected = false;
+ }
+ }
+ options = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ searchResults = [];
+ },
+ deselect_entry(entryId: string) {
+ if (!entryId || loading) {
+ console.log("Not deselecting entry due to failed preconditions", {
+ entryId,
+ loading,
+ });
+ return;
+ }
+ console.log("Deselecting entry", entryId);
+
+ const copy = options;
+
+ for (const entry of copy) {
+ if (entry.id === entryId) {
+ entry.selected = false;
+ }
+ }
+
+ options = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ },
+ get_sorted_array(options: Array<ComboboxOption>): Array<ComboboxOption> {
+ if (!options) {
+ return;
+ }
+ if (options.length < 1) {
+ return [];
+ }
+ if (searchValue) {
+ return options;
+ }
+
+ return options.sort((a, b) => search.normalise_value(a.name).localeCompare(search.normalise_value(b.name)));
+ },
+ };
+
+ const windowEvents = {
+ on_mousemove(event: any) {
+ if (!event.target) return;
+ mouseIsOverDropdown = event.target?.closest("#" + INTERNAL_ID + " .tongue") != null ?? false;
+ mouseIsOverComponent = event.target?.closest("#" + INTERNAL_ID) != null ?? false;
+ },
+ on_click() {
+ if (showDropdown && !mouseIsOverDropdown && !mouseIsOverComponent) {
+ showDropdown = false;
+ }
+ },
+ on_keydown(event: any) {
+ 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 = document.querySelector("#" + INTERNAL_ID + " ul li.focus") as HTMLLIElement;
+
+ if (showDropdown && (enterPressed || arrowDownPressed || arrowUpPressed)) {
+ event.preventDefault();
+ }
+
+ if (searchInputHasFocus && backspacePressed && !searchValue && options.length > 0) {
+ if (options.filter((c) => c.selected === true).at(-1)?.id ?? false) {
+ methods.deselect_entry(options.filter((c) => c.selected === true).at(-1)?.id ?? "");
+ }
+ return;
+ }
+
+ if (searchInputHasFocus && enterPressed && showCreationHint) {
+ methods.create_entry(searchValue.trim());
+ return;
+ }
+
+ if (searchInputHasFocus && !focusedEntry && arrowDownPressed) {
+ const firstEntry = document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type");
+ if (firstEntry) {
+ firstEntry.classList.add("focus");
+ return;
+ }
+ }
+
+ if (focusedEntry && (arrowUpPressed || arrowDownPressed)) {
+ if (arrowDownPressed) {
+ if (focusedEntry.nextElementSibling) {
+ focusedEntry.nextElementSibling.classList.add("focus");
+ focusedEntry.nextElementSibling.scrollIntoView(false);
+ } else {
+ const firstLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type");
+ firstLIEl.classList.add("focus");
+ firstLIEl.scrollIntoView(false);
+ }
+ } else if (arrowUpPressed) {
+ if (focusedEntry.previousElementSibling) {
+ focusedEntry.previousElementSibling.classList.add("focus");
+ focusedEntry.previousElementSibling.scrollIntoView(false);
+ } else {
+ const lastLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type");
+ lastLIEl.classList.add("focus");
+ lastLIEl.scrollIntoView(false);
+ }
+ }
+ focusedEntry.classList.remove("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);
+ },
+ };
+</script>
+
+<svelte:window
+ on:keydown={windowEvents.on_keydown}
+ on:mousemove={windowEvents.on_mousemove}
+ on:touchend={windowEvents.on_touchend}
+ on:click={windowEvents.on_click}
+/>
+
+<div id={INTERNAL_ID} class:cursor-wait={loading}>
+ {#if label}
+ <label for={id} class="block text-sm font-medium text-gray-700">
+ {label}
+ {@html required ? "<span class='text-red-500'>*</span>" : ""}
+ </label>
+ {/if}
+ <div class="relative {label ? 'mt-1' : ''}">
+ <div
+ on:click={search.on_input_wrapper_focus}
+ on:keypress={search.on_input_wrapper_focus}
+ class="cursor-text w-full flex rounded-md border bg-white py-2 pl-3 pr-12 sm:text-sm
+ {inputHasFocus ? `border-${colorName}-500 outline-none ring-1 ring-${colorName}-500` : 'shadow-sm border-gray-300'}"
+ >
+ {#if multiple === true && hasSelection}
+ <div class="flex gap-1 flex-wrap">
+ {#each options.filter((c) => c.selected === true) as option}
+ <Badge
+ id={option.id}
+ removable
+ tabindex="-1"
+ on:remove={(e) => methods.deselect_entry(e.detail.id)}
+ text={option.name}
+ />
+ {/each}
+ </div>
+ {/if}
+ <div>
+ <input
+ {...attributes}
+ type="text"
+ style="all: unset;"
+ role="combobox"
+ aria-controls={optionsListId}
+ aria-expanded={showDropdown}
+ bind:value={searchValue}
+ bind:this={searchInputNode}
+ on:input={() => search.do()}
+ on:click={search.on_input_click}
+ on:focus={search.on_input_focus}
+ on:blur={search.on_input_focusout}
+ autocomplete="off"
+ />
+ {#if hasSelection}
+ <button
+ type="button"
+ on:click={() => reset()}
+ title={$LL.reset()}
+ tabindex="-1"
+ class="text-gray-400 absolute cursor-pointer inset-y-0 right-0 flex items-center rounded-r-md px-2"
+ >
+ <XIcon />
+ </button>
+ {:else}
+ <span tabindex="-1" class="text-gray-400 absolute inset-y-0 right-0 flex items-center rounded-r-md px-2">
+ <ChevronUpDownIcon />
+ </span>
+ {/if}
+ </div>
+ </div>
+ {#if errorText}
+ <p class="mt-2 text-sm text-red-600" id={ariaErrorDescribedBy}>
+ {errorText}
+ </p>
+ {/if}
+ <div
+ class="tongue {showDropdown ? 'absolute' : 'hidden'}
+ z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white
+ text-base shadow-lg ring-1 ring-teal ring-opacity-5 focus:outline-none sm:text-sm"
+ >
+ <ul id={optionsListId} role="listbox" tabindex="-1">
+ {#if searchResults.length > 0}
+ {#each searchResults.filter((c) => !c.selected) as result}
+ <li
+ class="item"
+ data-id={result.obj.id}
+ aria-selected={result.obj.selected}
+ role="option"
+ on:click={on_select}
+ on:keypress={on_select}
+ tabindex="-1"
+ >
+ {@html highlight(result, '<span class="font-bold">', "</span>")}
+ </li>
+ {/each}
+ {:else if options.length > 0}
+ {#each options as option}
+ <!--
+ Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
+ Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+ -->
+ <li
+ class="item"
+ aria-selected={option.selected}
+ role="option"
+ data-id={option.id}
+ on:click={on_select}
+ on:keypress={on_select}
+ tabindex="-1"
+ >
+ <span class="block truncate {option.selected ? 'text-semibold' : ''}">{option.name}</span>
+ {#if option.selected}
+ <span class="absolute inset-y-0 right-0 flex items-center pr-4 text-{colorName}-600">
+ <CheckCircleIcon />
+ </span>
+ {/if}
+ </li>
+ {/each}
+ {:else}
+ <slot name="no-records">
+ <p class="px-2">{noResultsText}</p>
+ {#if createable && !searchValue}
+ <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p>
+ {/if}
+ </slot>
+ {/if}
+ </ul>
+ {#if showCreationHint}
+ <div class="sticky bottom-0 w-full bg-white">
+ <Button
+ text={$LL.combobox.createRecordButtonText(searchValue.trim())}
+ title={$LL.combobox.createRecordButtonText(searchValue.trim())}
+ {loading}
+ kind="reset"
+ type="button"
+ on:click={() => methods.create_entry(searchValue.trim())}
+ />
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style lang="postcss">
+ .focus {
+ @apply text-white bg-teal-300;
+ }
+
+ .item {
+ @apply relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900;
+ }
+
+ .item[aria-selected="true"] {
+ @apply bg-teal-200;
+ }
+</style>
diff --git a/code/app/src/components/icons/adjustments.svelte b/code/app/src/components/icons/adjustments.svelte
new file mode 100644
index 0000000..83bda27
--- /dev/null
+++ b/code/app/src/components/icons/adjustments.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
+ />
+</svg>
diff --git a/code/app/src/components/icons/bars-3-center-left.svelte b/code/app/src/components/icons/bars-3-center-left.svelte
new file mode 100644
index 0000000..785ece3
--- /dev/null
+++ b/code/app/src/components/icons/bars-3-center-left.svelte
@@ -0,0 +1,15 @@
+<svg
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5"
+ />
+</svg>
diff --git a/code/app/src/components/icons/calendar.svelte b/code/app/src/components/icons/calendar.svelte
new file mode 100644
index 0000000..e0053ee
--- /dev/null
+++ b/code/app/src/components/icons/calendar.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"
+ />
+</svg>
diff --git a/code/app/src/components/icons/check-circle.svelte b/code/app/src/components/icons/check-circle.svelte
new file mode 100644
index 0000000..e30778e
--- /dev/null
+++ b/code/app/src/components/icons/check-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/chevron-down.svelte b/code/app/src/components/icons/chevron-down.svelte
new file mode 100644
index 0000000..5b29ece
--- /dev/null
+++ b/code/app/src/components/icons/chevron-down.svelte
@@ -0,0 +1,7 @@
+<svg class="h-5 w-5 {$$restProps.class ?? ''}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
+ <path
+ fill-rule="evenodd"
+ d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/chevron-up-down.svelte b/code/app/src/components/icons/chevron-up-down.svelte
new file mode 100644
index 0000000..c07aed5
--- /dev/null
+++ b/code/app/src/components/icons/chevron-up-down.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/chevron-up.svelte b/code/app/src/components/icons/chevron-up.svelte
new file mode 100644
index 0000000..289e71d
--- /dev/null
+++ b/code/app/src/components/icons/chevron-up.svelte
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
+ <path
+ fill-rule="evenodd"
+ d="M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/database.svelte b/code/app/src/components/icons/database.svelte
new file mode 100644
index 0000000..6ffdadb
--- /dev/null
+++ b/code/app/src/components/icons/database.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
+ />
+</svg>
diff --git a/code/app/src/components/icons/exclamation-circle.svelte b/code/app/src/components/icons/exclamation-circle.svelte
new file mode 100644
index 0000000..2ce79b1
--- /dev/null
+++ b/code/app/src/components/icons/exclamation-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/exclamation-triangle.svelte b/code/app/src/components/icons/exclamation-triangle.svelte
new file mode 100644
index 0000000..8d807db
--- /dev/null
+++ b/code/app/src/components/icons/exclamation-triangle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/folder-open.svelte b/code/app/src/components/icons/folder-open.svelte
new file mode 100644
index 0000000..409c8e2
--- /dev/null
+++ b/code/app/src/components/icons/folder-open.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"
+ />
+</svg>
diff --git a/code/app/src/components/icons/funnel.svelte b/code/app/src/components/icons/funnel.svelte
new file mode 100644
index 0000000..7e9daeb
--- /dev/null
+++ b/code/app/src/components/icons/funnel.svelte
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
+ <path
+ fill-rule="evenodd"
+ d="M2.628 1.601C5.028 1.206 7.49 1 10 1s4.973.206 7.372.601a.75.75 0 01.628.74v2.288a2.25 2.25 0 01-.659 1.59l-4.682 4.683a2.25 2.25 0 00-.659 1.59v3.037c0 .684-.31 1.33-.844 1.757l-1.937 1.55A.75.75 0 018 18.25v-5.757a2.25 2.25 0 00-.659-1.591L2.659 6.22A2.25 2.25 0 012 4.629V2.34a.75.75 0 01.628-.74z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/home.svelte b/code/app/src/components/icons/home.svelte
new file mode 100644
index 0000000..ee8305d
--- /dev/null
+++ b/code/app/src/components/icons/home.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+</svg>
diff --git a/code/app/src/components/icons/index.ts b/code/app/src/components/icons/index.ts
new file mode 100644
index 0000000..eb5b439
--- /dev/null
+++ b/code/app/src/components/icons/index.ts
@@ -0,0 +1,47 @@
+import XIcon from "./x.svelte";
+import MenuIcon from "./menu.svelte";
+import AdjustmentsIcon from "./adjustments.svelte";
+import DatabaseIcon from "./database.svelte";
+import HomeIcon from "./home.svelte";
+import InformationCircleIcon from "./information-circle.svelte";
+import ExclamationTriangleIcon from "./exclamation-triangle.svelte";
+import XCircleIcon from "./x-circle.svelte";
+import CheckCircleIcon from "./check-circle.svelte";
+import XMarkIcon from "./x-mark.svelte";
+import SpinnerIcon from "./spinner.svelte";
+import ExclamationCircleIcon from "./exclamation-circle.svelte";
+import ChevronUpDownIcon from "./chevron-up-down.svelte";
+import MagnifyingGlassIcon from "./magnifying-glass.svelte";
+import Bars3CenterLeftIcon from "./bars-3-center-left.svelte";
+import CalendarIcon from "./calendar.svelte";
+import FolderOpenIcon from "./folder-open.svelte";
+import MegaphoneIcon from "./megaphone.svelte";
+import QueueListIcon from "./queue-list.svelte";
+import ChevronDownIcon from "./chevron-down.svelte";
+import ChevronUpIcon from "./chevron-up.svelte";
+import FunnelIcon from "./funnel.svelte";
+
+export {
+ FunnelIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+ QueueListIcon,
+ FolderOpenIcon,
+ MegaphoneIcon,
+ CalendarIcon,
+ Bars3CenterLeftIcon,
+ MagnifyingGlassIcon,
+ ChevronUpDownIcon,
+ XIcon,
+ MenuIcon,
+ HomeIcon,
+ DatabaseIcon,
+ AdjustmentsIcon,
+ InformationCircleIcon,
+ ExclamationTriangleIcon,
+ ExclamationCircleIcon,
+ XCircleIcon,
+ CheckCircleIcon,
+ XMarkIcon,
+ SpinnerIcon
+} \ No newline at end of file
diff --git a/code/app/src/components/icons/information-circle.svelte b/code/app/src/components/icons/information-circle.svelte
new file mode 100644
index 0000000..68dbc60
--- /dev/null
+++ b/code/app/src/components/icons/information-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M19 10.5a8.5 8.5 0 11-17 0 8.5 8.5 0 0117 0zM8.25 9.75A.75.75 0 019 9h.253a1.75 1.75 0 011.709 2.13l-.46 2.066a.25.25 0 00.245.304H11a.75.75 0 010 1.5h-.253a1.75 1.75 0 01-1.709-2.13l.46-2.066a.25.25 0 00-.245-.304H9a.75.75 0 01-.75-.75zM10 7a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/magnifying-glass.svelte b/code/app/src/components/icons/magnifying-glass.svelte
new file mode 100644
index 0000000..f8fdb6e
--- /dev/null
+++ b/code/app/src/components/icons/magnifying-glass.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/megaphone.svelte b/code/app/src/components/icons/megaphone.svelte
new file mode 100644
index 0000000..7ada5f3
--- /dev/null
+++ b/code/app/src/components/icons/megaphone.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
+ />
+</svg>
diff --git a/code/app/src/components/icons/menu.svelte b/code/app/src/components/icons/menu.svelte
new file mode 100644
index 0000000..471d85f
--- /dev/null
+++ b/code/app/src/components/icons/menu.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 6h16M4 12h16M4 18h16"
+ />
+</svg>
diff --git a/code/app/src/components/icons/queue-list.svelte b/code/app/src/components/icons/queue-list.svelte
new file mode 100644
index 0000000..6148394
--- /dev/null
+++ b/code/app/src/components/icons/queue-list.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
+ />
+</svg>
diff --git a/code/app/src/components/icons/spinner.svelte b/code/app/src/components/icons/spinner.svelte
new file mode 100644
index 0000000..80cc57c
--- /dev/null
+++ b/code/app/src/components/icons/spinner.svelte
@@ -0,0 +1,20 @@
+<svg
+ class="-ml-1 mr-3 h-5 w-5 animate-spin {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+>
+ <circle
+ class="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ stroke-width="4"
+ />
+ <path
+ class="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ />
+</svg>
diff --git a/code/app/src/components/icons/x-circle.svelte b/code/app/src/components/icons/x-circle.svelte
new file mode 100644
index 0000000..3793b5a
--- /dev/null
+++ b/code/app/src/components/icons/x-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/components/icons/x-mark.svelte b/code/app/src/components/icons/x-mark.svelte
new file mode 100644
index 0000000..fd1c6a1
--- /dev/null
+++ b/code/app/src/components/icons/x-mark.svelte
@@ -0,0 +1,11 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+ />
+</svg>
diff --git a/code/app/src/components/icons/x.svelte b/code/app/src/components/icons/x.svelte
new file mode 100644
index 0000000..6125ab8
--- /dev/null
+++ b/code/app/src/components/icons/x.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+</svg>
diff --git a/code/app/src/components/index.ts b/code/app/src/components/index.ts
new file mode 100644
index 0000000..494bc0c
--- /dev/null
+++ b/code/app/src/components/index.ts
@@ -0,0 +1,25 @@
+import Alert from "./alert.svelte";
+import Button from "./button.svelte";
+import Checkbox from "./checkbox.svelte";
+import Input from "./input.svelte";
+import LocaleSwitcher from "./locale-switcher.svelte";
+import Switch from "./switch.svelte";
+import Badge from "./badge.svelte";
+import ProjectStatusBadge from "./project-status-badge.svelte";
+import TextArea from "./textarea.svelte";
+import Combobox from "./combobox.svelte";
+import Notification from "./notification.svelte";
+
+export {
+ Badge,
+ Combobox,
+ TextArea,
+ ProjectStatusBadge,
+ Alert,
+ Button,
+ Checkbox,
+ Input,
+ LocaleSwitcher,
+ Switch,
+ Notification,
+}; \ No newline at end of file
diff --git a/code/app/src/components/input.svelte b/code/app/src/components/input.svelte
new file mode 100644
index 0000000..f97c1f1
--- /dev/null
+++ b/code/app/src/components/input.svelte
@@ -0,0 +1,112 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import {random_string} from "$utilities/misc-helpers";
+ import {ExclamationCircleIcon} from "./icons";
+
+ export let label: string | undefined = undefined;
+ export let type: string = "text";
+ export let autocomplete: string | undefined = undefined;
+ export let required: boolean | undefined = undefined;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let placeholder: string | undefined = undefined;
+ export let helpText: string | undefined = undefined;
+ export let errorText: string | undefined = undefined;
+ export let errors: Array<string> | undefined = undefined;
+ export let disabled = false;
+ export let hideLabel = false;
+ export let cornerHint: string | undefined = undefined;
+ export let icon: any = undefined;
+ export let addon: string | undefined = undefined;
+ export let value: string | undefined;
+ export let wrapperClass: string | undefined = undefined;
+ export let _pwKey: string | undefined = undefined;
+
+ $: ariaErrorDescribedBy = id + "__" + "error";
+ $: attributes = {
+ "aria-describedby": errorText || errors?.length ? ariaErrorDescribedBy : null,
+ "aria-invalid": errorText || errors?.length ? "true" : null,
+ disabled: disabled || null,
+ autocomplete: autocomplete || null,
+ required: required || null,
+ } as any;
+ $: hasBling = icon || addon || errorText;
+ const defaultColorClass = "border-gray-300 focus:border-teal-500 focus:ring-teal-500";
+ let colorClass = defaultColorClass;
+ $: if (errorText) {
+ colorClass = "placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500 text-red-900 pr-10 border-red-300";
+ } else {
+ colorClass = defaultColorClass;
+ }
+
+ function typeAction(node: HTMLInputElement) {
+ node.type = type;
+ }
+</script>
+
+<div class={wrapperClass}>
+ {#if label && !cornerHint && !hideLabel}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ {@html required ? "<span class='text-red-500'>*</span>" : ""}
+ </label>
+ {:else if cornerHint && !hideLabel}
+ <div class="flex justify-between">
+ {#if label}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ {@html required ? "<span class='text-red-500'>*</span>" : ""}
+ </label>
+ {/if}
+ <span class="text-sm text-gray-500">
+ {cornerHint}
+ </span>
+ </div>
+ {/if}
+ <div class="{label ? 'mt-1' : ''} {hasBling ? 'relative rounded-md' : ''} {addon ? 'flex' : ''}">
+ {#if icon}
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <svelte:component this={icon} class={errorText ? "text-red-500" : "text-gray-400"}/>
+ </div>
+ {:else if addon}
+ <div class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
+ <span class="text-gray-500 sm:text-sm">{addon}</span>
+ </div>
+ {/if}
+ <input
+ use:typeAction
+ use:pwKey={_pwKey}
+ {name}
+ {id}
+ {...attributes}
+ bind:value
+ class="block w-full rounded-md shadow-sm sm:text-sm
+ {colorClass}
+ {disabled ? 'disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500' : ''}
+ {addon ? 'min-w-0 flex-1 rounded-none rounded-r-md' : ''}
+ {icon ? 'pl-10' : ''}"
+ {placeholder}
+ />
+ {#if errorText}
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ <ExclamationCircleIcon class="text-red-500"/>
+ </div>
+ {/if}
+ </div>
+ {#if helpText && !errorText}
+ <p class="mt-2 text-sm text-gray-500">
+ {helpText}
+ </p>
+ {/if}
+ {#if errorText || errors?.length === 1}
+ <p class="mt-2 text-sm text-red-600" id={ariaErrorDescribedBy}>
+ {errorText ?? errors[0]}
+ </p>
+ {:else if errors && errors.length}
+ <ul class="mt-2 list-disc" id={ariaErrorDescribedBy}>
+ {#each errors as error}
+ <li class="text-sm text-red-600">{error}</li>
+ {/each}
+ </ul>
+ {/if}
+</div>
diff --git a/code/app/src/components/locale-switcher.svelte b/code/app/src/components/locale-switcher.svelte
new file mode 100644
index 0000000..fc03f39
--- /dev/null
+++ b/code/app/src/components/locale-switcher.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import {browser} from "$app/environment";
+ import {page} from "$app/stores";
+ import {CookieNames} from "$configuration";
+ import {setLocale, locale} from "$i18n/i18n-svelte";
+ import type {Locales} from "$i18n/i18n-types";
+ import {locales} from "$i18n/i18n-util";
+ import {loadLocaleAsync} from "$i18n/i18n-util.async";
+ import Cookies from "js-cookie";
+
+ export let _pwKey: string | undefined = undefined;
+ export let tabindex: number | undefined = undefined;
+ let currentLocale = Cookies.get(CookieNames.locale);
+
+ async function switch_locale(newLocale: Locales) {
+ if (!newLocale || $locale === newLocale) return;
+ await loadLocaleAsync(newLocale);
+ setLocale(newLocale);
+ document.querySelector("html")?.setAttribute("lang", newLocale);
+ Cookies.set(CookieNames.locale, newLocale);
+ currentLocale = newLocale;
+ console.log("Switched to: " + newLocale);
+ }
+
+ function on_change(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ switch_locale(target.options[target.selectedIndex].value as Locales);
+ }
+
+ $: if (browser) {
+ switch_locale($page.params.lang as Locales);
+ }
+
+ function get_locale_name(iso: string) {
+ switch (iso) {
+ case "nb": {
+ return "Norsk Bokmål";
+ }
+ case "en": {
+ return "English";
+ }
+ }
+ }
+</script>
+
+<select
+ {tabindex}
+ use:pwKey={_pwKey}
+ on:change={on_change}
+ class="mt-1 mr-1 block border-none py-2 pl-3 pr-10 text-base rounded-md right-0 absolute focus:outline-none focus:ring-teal-500 sm:text-sm"
+>
+ {#each locales as aLocale}
+ <option value={aLocale} selected={aLocale === currentLocale}>{get_locale_name(aLocale)}</option>
+ {/each}
+</select>
diff --git a/code/app/src/components/notification.svelte b/code/app/src/components/notification.svelte
new file mode 100644
index 0000000..d78b3d3
--- /dev/null
+++ b/code/app/src/components/notification.svelte
@@ -0,0 +1,119 @@
+<script context="module" lang="ts">
+ export type NotificationType = "info" | "error" | "success" | "warning" | "subtle";
+</script>
+
+<script lang="ts">
+ import { Transition } from "@rgossiaux/svelte-headlessui";
+ import { onDestroy } from "svelte";
+ import { XMarkIcon, ExclamationCircleIcon, InformationCircleIcon, XCircleIcon, CheckCircleIcon } from "./icons";
+
+ export let title: string;
+ export let subtitle = "";
+ export let show = false;
+ export let type: NotificationType = "info";
+ export let hideAfterSeconds = -1;
+ export let nonClosable = false;
+
+ $: _show = show && title.length > 0;
+ let timeout;
+ let iconClass = "";
+ let icon = undefined;
+ let bgClass = "";
+ let ringClass = "";
+
+ onDestroy(() => {
+ clearTimeout(timeout);
+ });
+
+ $: if (hideAfterSeconds > 0) {
+ timeout = setTimeout(() => close(), hideAfterSeconds * 1000);
+ } else {
+ timeout = -1;
+ show = true;
+ }
+
+ $: switch (type) {
+ case "error":
+ iconClass = "text-red-400";
+ bgClass = "bg-red-50";
+ ringClass = "ring-1 ring-red-100";
+ icon = XCircleIcon;
+ break;
+ case "info":
+ iconClass = "text-blue-400";
+ bgClass = "bg-blue-50";
+ ringClass = "ring-1 ring-blue-100";
+ icon = InformationCircleIcon;
+ break;
+ case "success":
+ iconClass = "text-green-400";
+ bgClass = "bg-green-50";
+ ringClass = "ring-1 ring-green-100";
+ icon = CheckCircleIcon;
+ break;
+ case "warning":
+ iconClass = "text-yellow-400";
+ bgClass = "bg-yellow-50";
+ ringClass = "ring-1 ring-yellow-100";
+ icon = ExclamationCircleIcon;
+ break;
+ case "subtle":
+ icon = undefined;
+ bgClass = "bg-white";
+ ringClass = "ring-1 ring-gray-100";
+ break;
+ default:
+ icon = undefined;
+ bgClass = "bg-white";
+ ringClass = "";
+ break;
+ }
+
+ function close() {
+ show = false;
+ }
+</script>
+
+<div aria-live="assertive" class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6">
+ <div class="flex w-full flex-col items-center space-y-4 sm:items-end">
+ <Transition
+ class="w-full flex justify-end"
+ show={_show}
+ enter="transform ease-out duration-300 transition"
+ enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
+ enterTo="translate-y-0 opacity-100 sm:translate-x-0"
+ leave="transition ease-in duration-100"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg shadow-md {bgClass} {ringClass}">
+ <div class="p-4">
+ <div class="flex items-start">
+ {#if icon}
+ <div class="flex-shrink-0">
+ <svelte:component this={icon} class={iconClass} />
+ </div>
+ {/if}
+ <div class="ml-3 w-0 flex-1 pt-0.5">
+ <p class="text-sm font-medium text-gray-900">{title}</p>
+ {#if subtitle}
+ <p class="mt-1 text-sm text-gray-500">{subtitle}</p>
+ {/if}
+ </div>
+ {#if !nonClosable}
+ <div class="ml-4 flex flex-shrink-0">
+ <button
+ on:click={close}
+ type="button"
+ class="inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+ >
+ <XMarkIcon />
+ </button>
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </Transition>
+ </div>
+</div>
diff --git a/code/app/src/components/project-status-badge.svelte b/code/app/src/components/project-status-badge.svelte
new file mode 100644
index 0000000..3e93935
--- /dev/null
+++ b/code/app/src/components/project-status-badge.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import type {ProjectStatus} from "$models/projects/ProjectStatus";
+ import Badge from "./badge.svelte";
+
+ export let status: string | ProjectStatus;
+
+ let text = "";
+ let type = "default" as any;
+ $: switch (status) {
+ case "idl":
+ type = "tame";
+ text = "IDLE";
+ break;
+ case "exp":
+ type = "yellow";
+ text = "EXPIRED";
+ break;
+ case "act":
+ type = "green";
+ text = "ACTIVE";
+ break;
+ }
+</script>
+
+<Badge {text} {type} uppercase/>
diff --git a/code/app/src/components/switch.svelte b/code/app/src/components/switch.svelte
new file mode 100644
index 0000000..1b67f80
--- /dev/null
+++ b/code/app/src/components/switch.svelte
@@ -0,0 +1,125 @@
+<script context="module" lang="ts">
+ export type SwitchType = "short" | "icon" | "default";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+
+ export let enabled = false;
+ export let type: SwitchType = "default";
+ export let srText = "Use setting";
+ export let label: string | undefined = undefined;
+ export let description: string | undefined = undefined;
+ export let rightAlignedLabelDescription = false;
+ export let _pwKey: string | undefined = undefined;
+
+ $: colorClass = enabled ? "bg-teal-600 focus:ring-teal-500" : "bg-gray-200 focus:ring-teal-500";
+ $: translateClass = enabled ? "translate-x-5" : "translate-x-0";
+ $: hasLabelOrDescription = label || description;
+
+ function toggle() {
+ enabled = !enabled;
+ }
+</script>
+
+<div class="{hasLabelOrDescription ? 'flex items-center' : ''} {rightAlignedLabelDescription ? '' : 'justify-between'}">
+ {#if hasLabelOrDescription && !rightAlignedLabelDescription}
+ <span class="flex flex-grow flex-col">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+ {#if type === "short"}
+ <button
+ type="button"
+ class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span aria-hidden="true" class="pointer-events-none absolute h-full w-full rounded-md"/>
+ <span
+ aria-hidden="true"
+ class="{colorClass} pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"
+ />
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out"
+ />
+ </button>
+ {:else if type === "icon"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ class="{translateClass} pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ >
+ <span
+ class="{enabled
+ ? 'opacity-0 ease-out duration-100'
+ : 'opacity-100 ease-in duration-200'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 12 12">
+ <path
+ d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </span>
+ <span
+ class="{enabled
+ ? 'opacity-100 ease-in duration-200'
+ : 'opacity-0 ease-out duration-100'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg class="h-3 w-3 text-indigo-600" fill="currentColor" viewBox="0 0 12 12">
+ <path
+ d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
+ />
+ </svg>
+ </span>
+ </span>
+ </button>
+ {:else if type === "default"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ />
+ </button>
+ {/if}
+ {#if hasLabelOrDescription && rightAlignedLabelDescription}
+ <span class="ml-3">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+</div>
diff --git a/code/app/src/components/textarea.svelte b/code/app/src/components/textarea.svelte
new file mode 100644
index 0000000..223a265
--- /dev/null
+++ b/code/app/src/components/textarea.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import {random_string} from "$utilities/misc-helpers";
+
+ export let id = "textarea-" + random_string(4);
+ export let disabled = false;
+ export let rows = 2;
+ export let cols = 0;
+ export let name = "";
+ export let placeholder = "";
+ export let value;
+ export let label = "";
+ export let required = false;
+ export let errorText = "";
+ export let errors: Array<string> | undefined = undefined;
+
+ $: ariaErrorDescribedBy = id + "__" + "error";
+ $: attributes = {
+ "aria-describedby": errorText || errors?.length ? ariaErrorDescribedBy : null,
+ "aria-invalid": errorText || errors?.length ? "true" : null,
+ rows: rows || null,
+ cols: cols || null,
+ name: name || null,
+ id: id || null,
+ disabled: disabled || null,
+ required: required || null,
+ } as any;
+
+ let textareaElement;
+ let scrollHeight = 0;
+ const defaultColorClass = "border-gray-300 focus:border-teal-500 focus:ring-teal-500";
+ let colorClass = defaultColorClass;
+
+ $: if (errorText) {
+ colorClass = "placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500 text-red-900 pr-10 border-red-300";
+ } else {
+ colorClass = defaultColorClass;
+ }
+
+ $: if (textareaElement) {
+ scrollHeight = textareaElement.scrollHeight;
+ }
+
+ function on_input(event) {
+ event.target.style.height = "auto";
+ event.target.style.height = this.scrollHeight + "px";
+ }
+</script>
+
+<div>
+ {#if label}
+ <label for={id} class="block text-sm font-medium text-gray-700">
+ {label}
+ {@html required ? "<span class='text-red-500'>*</span>" : ""}
+ </label>
+ {/if}
+ <div class="mt-1">
+ <textarea
+ {rows}
+ {name}
+ {id}
+ {...attributes}
+ style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};"
+ bind:value
+ bind:this={textareaElement}
+ on:input={on_input}
+ {placeholder}
+ class="block w-full rounded-md {colorClass} shadow-sm sm:text-sm"
+ />
+ {#if errorText || errors?.length === 1}
+ <p class="mt-2 text-sm text-red-600" id={ariaErrorDescribedBy}>
+ {errorText ?? errors[0]}
+ </p>
+ {:else if errors && errors.length}
+ <ul class="mt-2 list-disc" id={ariaErrorDescribedBy}>
+ {#each errors as error}
+ <li class="text-sm text-red-600">{error}</li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+</div>