aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'code/app/src')
-rw-r--r--code/app/src/lib/components/combobox.svelte443
-rw-r--r--code/app/src/lib/components/index.ts4
-rw-r--r--code/app/src/lib/components/textarea.svelte64
-rw-r--r--code/app/src/lib/i18n/en/index.ts6
-rw-r--r--code/app/src/lib/i18n/i18n-types.ts37
-rw-r--r--code/app/src/routes/book/badges/+page.svelte4
-rw-r--r--code/app/src/routes/book/inputs/+page.svelte57
7 files changed, 595 insertions, 20 deletions
diff --git a/code/app/src/lib/components/combobox.svelte b/code/app/src/lib/components/combobox.svelte
new file mode 100644
index 0000000..ee69917
--- /dev/null
+++ b/code/app/src/lib/components/combobox.svelte
@@ -0,0 +1,443 @@
+<script lang="ts" context="module">
+ export type ComboboxOption = {
+ id: string;
+ name: string;
+ selected?: boolean;
+ };
+</script>
+
+<script lang="ts">
+ import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "$lib/components/icons";
+ import { element_has_focus, random_string } from "$lib/helpers";
+ import { go, highlight } from "fuzzysort";
+ import Badge from "./badge.svelte";
+ import Button from "./button.svelte";
+ import LL from "$lib/i18n/i18n-svelte";
+
+ 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 = $LL.combobox.search();
+ export let options: Array<ComboboxOption> | undefined = [];
+ export let createable = false;
+ export let loading = false;
+ export let multiple = false;
+ export let noResultsText = $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__combobox-" + random_string(3);
+
+ 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.toString().trim().toLowerCase();
+ },
+ do() {
+ const query = search.normalise_value(searchValue);
+
+ if (!query.trim()) {
+ searchResults = [];
+ return;
+ }
+
+ //@ts-ignore
+ searchResults = go(query, options, {
+ limit: 10,
+ 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 as any).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 .focus");
+
+ 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 {
+ document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").classList.add("focus");
+ document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").scrollIntoView(false);
+ }
+ } else if (arrowUpPressed) {
+ if (focusedEntry.previousElementSibling) {
+ focusedEntry.previousElementSibling.classList.add("focus");
+ focusedEntry.previousElementSibling.scrollIntoView(false);
+ } else {
+ document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").classList.add("focus");
+ document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").scrollIntoView(false);
+ }
+ }
+ focusedEntry.classList.remove("focus");
+ return;
+ }
+
+ if (focusedEntry && (spacePressed || enterPressed)) {
+ //@ts-ignore
+ 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}</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 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 class={multiple === true && hasSelection ? "mt-2" : ""}>
+ <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()}
+ class="text-gray-400 absolute cursor-pointer inset-y-0 right-0 flex items-center rounded-r-md px-2"
+ >
+ <XIcon />
+ </button>
+ {:else}
+ <span 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}
+ <p class="px-2">{noResultsText}</p>
+ {#if createable && !searchValue}
+ <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p>
+ {/if}
+ {/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/lib/components/index.ts b/code/app/src/lib/components/index.ts
index 5ed1f09..d6abd4c 100644
--- a/code/app/src/lib/components/index.ts
+++ b/code/app/src/lib/components/index.ts
@@ -6,9 +6,13 @@ 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";
export {
Badge,
+ Combobox,
+ TextArea,
ProjectStatusBadge,
Alert,
Button,
diff --git a/code/app/src/lib/components/textarea.svelte b/code/app/src/lib/components/textarea.svelte
new file mode 100644
index 0000000..65127af
--- /dev/null
+++ b/code/app/src/lib/components/textarea.svelte
@@ -0,0 +1,64 @@
+<script lang="ts">
+ import { random_string } from "$lib/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 errorText = "";
+
+ $: shared_props = {
+ rows: rows || null,
+ cols: cols || null,
+ name: name || null,
+ id: id || null,
+ disabled: disabled || null,
+ };
+
+ let textarea;
+ 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 (textarea) {
+ scrollHeight = textarea.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}</label>
+ {/if}
+ <div class="mt-1">
+ <textarea
+ {rows}
+ {name}
+ {id}
+ {...shared_props}
+ style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};"
+ bind:value
+ bind:this={textarea}
+ on:input={on_input}
+ {placeholder}
+ class="block w-full rounded-md {colorClass} shadow-sm sm:text-sm"
+ />
+ {#if errorText}
+ <p class="mt-2 text-sm text-red-600">
+ {errorText}
+ </p>
+ {/if}
+ </div>
+</div>
diff --git a/code/app/src/lib/i18n/en/index.ts b/code/app/src/lib/i18n/en/index.ts
index 1d6ff26..b9cdae7 100644
--- a/code/app/src/lib/i18n/en/index.ts
+++ b/code/app/src/lib/i18n/en/index.ts
@@ -20,6 +20,12 @@ const en: BaseTranslation = {
tos: "Terms of service",
privacyPolicy: "Privacy policy",
signIntoYourAccount: "Sign into your account",
+ combobox: {
+ search: "Search",
+ noRecordsFound: "No records found",
+ createRecordHelpText: "Create a record by typing the name in the search bar and pressing enter",
+ createRecordButtonText: "Press enter or click here to create {0}"
+ },
signInPage: {
notMyComputer: "This is not my computer",
resetPassword: "Reset password",
diff --git a/code/app/src/lib/i18n/i18n-types.ts b/code/app/src/lib/i18n/i18n-types.ts
index 2c564c4..63387e8 100644
--- a/code/app/src/lib/i18n/i18n-types.ts
+++ b/code/app/src/lib/i18n/i18n-types.ts
@@ -96,6 +96,25 @@ type RootTranslation = {
* S​i​g​n​ ​i​n​t​o​ ​y​o​u​r​ ​a​c​c​o​u​n​t
*/
signIntoYourAccount: string
+ combobox: {
+ /**
+ * S​e​a​r​c​h
+ */
+ search: string
+ /**
+ * N​o​ ​r​e​c​o​r​d​s​ ​f​o​u​n​d
+ */
+ noRecordsFound: string
+ /**
+ * C​r​e​a​t​e​ ​a​ ​r​e​c​o​r​d​ ​b​y​ ​t​y​p​i​n​g​ ​t​h​e​ ​n​a​m​e​ ​i​n​ ​t​h​e​ ​s​e​a​r​c​h​ ​b​a​r​ ​a​n​d​ ​p​r​e​s​s​i​n​g​ ​e​n​t​e​r
+ */
+ createRecordHelpText: string
+ /**
+ * P​r​e​s​s​ ​e​n​t​e​r​ ​o​r​ ​c​l​i​c​k​ ​h​e​r​e​ ​t​o​ ​c​r​e​a​t​e​ ​{​0​}
+ * @param {unknown} 0
+ */
+ createRecordButtonText: RequiredParams<'0'>
+ }
signInPage: {
/**
* T​h​i​s​ ​i​s​ ​n​o​t​ ​m​y​ ​c​o​m​p​u​t​e​r
@@ -274,6 +293,24 @@ export type TranslationFunctions = {
* Sign into your account
*/
signIntoYourAccount: () => LocalizedString
+ combobox: {
+ /**
+ * Search
+ */
+ search: () => LocalizedString
+ /**
+ * No records found
+ */
+ noRecordsFound: () => LocalizedString
+ /**
+ * Create a record by typing the name in the search bar and pressing enter
+ */
+ createRecordHelpText: () => LocalizedString
+ /**
+ * Press enter or click here to create {0}
+ */
+ createRecordButtonText: (arg0: unknown) => LocalizedString
+ }
signInPage: {
/**
* This is not my computer
diff --git a/code/app/src/routes/book/badges/+page.svelte b/code/app/src/routes/book/badges/+page.svelte
index 1e06a7c..cd5120a 100644
--- a/code/app/src/routes/book/badges/+page.svelte
+++ b/code/app/src/routes/book/badges/+page.svelte
@@ -12,7 +12,7 @@
<Badge type="yellow" text="yellow" />
<Badge size="large" text="large" />
<Badge text="with dot" withDot type="blue" />
- <Badge text="removable" removeable id="badge-1" on:remove={(e) => alert("removed " + e.detail.id)} />
+ <Badge text="removable" removable id="badge-1" on:remove={(e) => alert("removed " + e.detail.id)} />
<Badge text="with dot" size="large" withDot type="blue" />
- <Badge text="removable" removeable size="large" id="badge-2" uppercase on:remove={(e) => alert("removed " + e.detail.id)} />
+ <Badge text="removable" removable size="large" id="badge-2" uppercase on:remove={(e) => alert("removed " + e.detail.id)} />
</section>
diff --git a/code/app/src/routes/book/inputs/+page.svelte b/code/app/src/routes/book/inputs/+page.svelte
index a693f69..e4d19ff 100644
--- a/code/app/src/routes/book/inputs/+page.svelte
+++ b/code/app/src/routes/book/inputs/+page.svelte
@@ -1,48 +1,69 @@
<script lang="ts">
- import Input from "$lib/components/input.svelte";
+ import { TextArea, Input, Combobox } from "$lib/components";
import { DatabaseIcon } from "$lib/components/icons";
+
+ let value;
+ let i = 0;
+ let options = [];
+ let tempOptions = [];
+ while (i < 101) {
+ tempOptions.push({
+ id: crypto.randomUUID(),
+ name: "Option " + i,
+ });
+ options = tempOptions;
+ i++;
+ }
+
+ async function add({ name }) {
+ const copy = options;
+ copy.push({
+ id: crypto.randomUUID(),
+ name: name,
+ });
+ options = copy;
+ }
</script>
<section>
+ <h2>Combobox</h2>
+ <Combobox {options} label="Wiii" multiple createable on_create_async={add} />
+</section>
+
+<section>
<h2>Default</h2>
- <Input label="Input me" placeholder="Hello" />
+ <Input label="Input me" placeholder="Hello" bind:value />
</section>
<section>
<h2>With icon</h2>
- <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} />
+ <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} bind:value />
</section>
<section>
<h2>With corner hint</h2>
- <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" />
+ <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" bind:value />
</section>
<section>
<h2>Disabled</h2>
- <Input label="No" placeholder="Sorry" disabled />
+ <Input label="No" placeholder="Sorry" disabled bind:value />
</section>
<section>
<h2>Errored</h2>
- <Input
- label="No"
- placeholder="Sorry"
- errorText="That's not right"
- icon={DatabaseIcon}
- />
+ <Input label="No" placeholder="Sorry" errorText="That's not right" bind:value icon={DatabaseIcon} />
</section>
<section>
<h2>Help</h2>
- <Input label="Go ahead" placeholder="Write here" helpText="Write above" />
+ <Input label="Go ahead" placeholder="Write here" helpText="Write above" bind:value />
</section>
<section>
<h2>Addon</h2>
- <Input
- label="Go ahead"
- placeholder="Write here"
- helpText="Write above"
- addon="To the right"
- />
+ <Input label="Go ahead" placeholder="Write here" bind:value helpText="Write above" addon="To the right" />
+</section>
+<section>
+ <h2>Textarea</h2>
+ <TextArea bind:value errorText="oh no" label="Hi" />
</section>