diff options
| author | ivarlovlie <git@ivarlovlie.no> | 2022-06-01 22:10:32 +0200 |
|---|---|---|
| committer | ivarlovlie <git@ivarlovlie.no> | 2022-06-01 22:10:32 +0200 |
| commit | a640703f2da8815dc26ad1600a6f206be1624379 (patch) | |
| tree | dbda195fb5783d16487e557e06471cf848b75427 /apps/web-shared/src/components | |
| download | greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip | |
feat: Initial after clean slate
Diffstat (limited to 'apps/web-shared/src/components')
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> |
