mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-29 01:58:01 +07:00
refactor calc formula builder to token
This commit is contained in:
parent
1860b62d73
commit
05ee4617d5
@ -11,42 +11,135 @@
|
|||||||
let props = $props();
|
let props = $props();
|
||||||
|
|
||||||
const operators = ['+', '-', '*', '/', '^', '(', ')'];
|
const operators = ['+', '-', '*', '/', '^', '(', ')'];
|
||||||
let expression = $state('');
|
const logicalop = ['IF', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT', 'MIN', 'MAX'];
|
||||||
let cursorPosition = $state(0);
|
const comparisonop = ['=', '!=', '<', '>', '<=', '>='];
|
||||||
|
|
||||||
function unselectAll(key) {
|
let tokens = $state([]);
|
||||||
|
let cursorIndex = $state(0);
|
||||||
|
let showLiteralPopover = $state(false);
|
||||||
|
let literalPopoverType = $state(('string'));
|
||||||
|
$inspect(tokens)
|
||||||
|
// let expression = $state('');
|
||||||
|
// let cursorPosition = $state(0);
|
||||||
|
|
||||||
|
function uid() {
|
||||||
|
return Math.random().toString(36).slice(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertToken(type, value) {
|
||||||
|
const token = { id: uid(), type, value };
|
||||||
|
tokens = [...tokens.slice(0, cursorIndex), token, ...tokens.slice(cursorIndex)];
|
||||||
|
cursorIndex += 1;
|
||||||
|
syncFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addValue(val) {
|
||||||
|
insertToken('test', val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOperator(op) {
|
||||||
|
const isLogical = logicalop.includes(op);
|
||||||
|
const isComparison = comparisonop.includes(op);
|
||||||
|
const type = isLogical ? 'keyword' : isComparison ? 'comparator' : 'operator';
|
||||||
|
insertToken(type, op);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLiteral(val) {
|
||||||
|
insertToken('literal', val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNewline() {
|
||||||
|
insertToken('newline', '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorLeft() {
|
||||||
|
if (cursorIndex > 0) cursorIndex -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorRight() {
|
||||||
|
if (cursorIndex < tokens.length) cursorIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChar() {
|
||||||
|
if (cursorIndex > 0) {
|
||||||
|
tokens = [...tokens.slice(0, cursorIndex - 1), ...tokens.slice(cursorIndex)];
|
||||||
|
cursorIndex -= 1;
|
||||||
|
syncFormState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExpression() {
|
||||||
|
tokens = [];
|
||||||
|
cursorIndex = 0;
|
||||||
|
syncFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFormState() {
|
||||||
|
const code = tokens
|
||||||
|
.map((t) => (t.type === 'newline' ? '\n' : t.value))
|
||||||
|
.join(' ')
|
||||||
|
.replace(/ \n /g, '\n');
|
||||||
|
props.calFormState.form.FormulaCode = code;
|
||||||
|
props.calFormState.validateField?.('FormulaCode', code, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorStatus() {
|
||||||
|
const selected = props.calFormState.form.FormulaInput;
|
||||||
|
if (!Array.isArray(selected)) return [];
|
||||||
|
return selected.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
done: tokens.some((t) => t.type === 'test' && t.value === item.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLiteralPopover(type) {
|
||||||
|
literalPopoverType = type;
|
||||||
|
showLiteralPopover = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLiteralConfirm(val) {
|
||||||
|
addLiteral(val);
|
||||||
|
showLiteralPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unselectAll(key) {
|
||||||
props.calFormState.form[key] = [];
|
props.calFormState.form[key] = [];
|
||||||
props.calFormState.validateField?.(key, [], false);
|
props.calFormState.validateField?.(key, [], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorStatus(formulaCode = '') {
|
// function unselectAll(key) {
|
||||||
const selected = props.calFormState.form.FormulaInput;
|
// props.calFormState.form[key] = [];
|
||||||
if (!Array.isArray(selected)) return [];
|
// props.calFormState.validateField?.(key, [], false);
|
||||||
|
// }
|
||||||
|
|
||||||
return selected.map((item) => ({
|
// function getErrorStatus(formulaCode = '') {
|
||||||
value: item.value,
|
// const selected = props.calFormState.form.FormulaInput;
|
||||||
done: new RegExp(`\\b${item.value}\\b`, 'i').test(formulaCode)
|
// if (!Array.isArray(selected)) return [];
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToExpression(text) {
|
// return selected.map((item) => ({
|
||||||
const before = expression.slice(0, cursorPosition);
|
// value: item.value,
|
||||||
const after = expression.slice(cursorPosition);
|
// done: new RegExp(`\\b${item.value}\\b`, 'i').test(formulaCode)
|
||||||
expression = before + text + after;
|
// }));
|
||||||
cursorPosition += text.length;
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
function addOperator(op) {
|
// function addToExpression(text) {
|
||||||
addToExpression(op);
|
// const before = expression.slice(0, cursorPosition);
|
||||||
props.calFormState.form.FormulaCode = expression;
|
// const after = expression.slice(cursorPosition);
|
||||||
props.calFormState.validateField?.('FormulaCode', expression, false);
|
// expression = before + text + after;
|
||||||
}
|
// cursorPosition += text.length;
|
||||||
|
// }
|
||||||
|
|
||||||
function addValue(val) {
|
// function addOperator(op) {
|
||||||
addToExpression(val);
|
// addToExpression(op);
|
||||||
props.calFormState.form.FormulaCode = expression;
|
// props.calFormState.form.FormulaCode = expression;
|
||||||
props.calFormState.validateField?.('FormulaCode', expression, false);
|
// props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
// function addValue(val) {
|
||||||
|
// addToExpression(val);
|
||||||
|
// props.calFormState.form.FormulaCode = expression;
|
||||||
|
// props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
|
// }
|
||||||
|
|
||||||
// function handleInput(e) {
|
// function handleInput(e) {
|
||||||
// expression = e.target.value;
|
// expression = e.target.value;
|
||||||
@ -59,63 +152,67 @@
|
|||||||
// cursorPosition = e.target.selectionStart;
|
// cursorPosition = e.target.selectionStart;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
function handleContainerClick(e) {
|
// function handleContainerClick(e) {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
// const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const text = expression;
|
// const text = expression;
|
||||||
const charWidth = 8.5;
|
// const charWidth = 8.5;
|
||||||
const padding = 12;
|
// const padding = 12;
|
||||||
const clickX = e.clientX - rect.left - padding;
|
// const clickX = e.clientX - rect.left - padding;
|
||||||
let newPos = Math.floor(clickX / charWidth);
|
// let newPos = Math.floor(clickX / charWidth);
|
||||||
newPos = Math.max(0, Math.min(newPos, text.length));
|
// newPos = Math.max(0, Math.min(newPos, text.length));
|
||||||
cursorPosition = newPos;
|
// cursorPosition = newPos;
|
||||||
}
|
// }
|
||||||
|
|
||||||
function moveCursorLeft() {
|
// function moveCursorLeft() {
|
||||||
if (cursorPosition > 0) {
|
// if (cursorPosition > 0) {
|
||||||
cursorPosition -= 1;
|
// cursorPosition -= 1;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
function moveCursorRight() {
|
// function moveCursorRight() {
|
||||||
if (cursorPosition < expression.length) {
|
// if (cursorPosition < expression.length) {
|
||||||
cursorPosition += 1;
|
// cursorPosition += 1;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
function deleteChar() {
|
// function deleteChar() {
|
||||||
if (cursorPosition > 0) {
|
// if (cursorPosition > 0) {
|
||||||
const before = expression.slice(0, cursorPosition - 1);
|
// const before = expression.slice(0, cursorPosition - 1);
|
||||||
const after = expression.slice(cursorPosition);
|
// const after = expression.slice(cursorPosition);
|
||||||
expression = before + after;
|
// expression = before + after;
|
||||||
cursorPosition -= 1;
|
// cursorPosition -= 1;
|
||||||
props.calFormState.form.FormulaCode = expression;
|
// props.calFormState.form.FormulaCode = expression;
|
||||||
props.calFormState.validateField?.('FormulaCode', expression, false);
|
// props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
function clearExpression() {
|
// function clearExpression() {
|
||||||
expression = '';
|
// expression = '';
|
||||||
cursorPosition = 0;
|
// cursorPosition = 0;
|
||||||
props.calFormState.form.FormulaCode = expression;
|
// props.calFormState.form.FormulaCode = expression;
|
||||||
props.calFormState.validateField?.('FormulaCode', expression, false);
|
// props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
}
|
// }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<DictionaryFormRenderer
|
<DictionaryFormRenderer
|
||||||
formState={props.calFormState}
|
formState={props.calFormState}
|
||||||
formFields={props.testCalFormFields}
|
formFields={props.testCalFormFields}
|
||||||
operators={operators}
|
operators={operators}
|
||||||
expression={expression}
|
logicalop={logicalop}
|
||||||
cursorPosition={cursorPosition}
|
comparisonop={comparisonop}
|
||||||
onUnselectAll={unselectAll}
|
tokens={tokens}
|
||||||
onGetErrorStatus={getErrorStatus}
|
cursorIndex={cursorIndex}
|
||||||
onAddOperator={addOperator}
|
onUnselectAll={unselectAll}
|
||||||
onAddValue={addValue}
|
onGetErrorStatus={getErrorStatus}
|
||||||
onHandleContainerClick={handleContainerClick}
|
onAddOperator={addOperator}
|
||||||
onMoveCursorLeft={moveCursorLeft}
|
onAddValue={addValue}
|
||||||
onMoveCursorRight={moveCursorRight}
|
onAddLiteral={onLiteralConfirm}
|
||||||
onDeleteChar={deleteChar}
|
onMoveCursorLeft={moveCursorLeft}
|
||||||
onClearExpression={clearExpression}
|
onMoveCursorRight={moveCursorRight}
|
||||||
/>
|
onDeleteChar={deleteChar}
|
||||||
|
onClearExpression={clearExpression}
|
||||||
|
onAddNewline={addNewline}
|
||||||
|
onSetCursor={(i) => (cursorIndex = i)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<script>
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { testCalSchema } from '$lib/components/dictionary/test/config/test-form-config';
|
||||||
|
import { API } from '$lib/config/api';
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import DictionaryFormRenderer from '$lib/components/reusable/form/dictionary-form-renderer.svelte';
|
||||||
|
|
||||||
|
let props = $props();
|
||||||
|
|
||||||
|
const operators = ['+', '-', '*', '/', '^', '(', ')'];
|
||||||
|
const logicalop = ['IF', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT', 'MIN', 'MAX'];
|
||||||
|
const comparisonop = ['=', '!=', '<', '>', '<=', '>='];
|
||||||
|
const customval = ['abc', '123'];
|
||||||
|
|
||||||
|
let expression = $state('');
|
||||||
|
let cursorPosition = $state(0);
|
||||||
|
|
||||||
|
function unselectAll(key) {
|
||||||
|
props.calFormState.form[key] = [];
|
||||||
|
props.calFormState.validateField?.(key, [], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorStatus(formulaCode = '') {
|
||||||
|
const selected = props.calFormState.form.FormulaInput;
|
||||||
|
if (!Array.isArray(selected)) return [];
|
||||||
|
|
||||||
|
return selected.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
done: new RegExp(`\\b${item.value}\\b`, 'i').test(formulaCode)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToExpression(text) {
|
||||||
|
const before = expression.slice(0, cursorPosition);
|
||||||
|
const after = expression.slice(cursorPosition);
|
||||||
|
expression = before + text + after;
|
||||||
|
cursorPosition += text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOperator(op) {
|
||||||
|
addToExpression(op);
|
||||||
|
props.calFormState.form.FormulaCode = expression;
|
||||||
|
props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addValue(val) {
|
||||||
|
addToExpression(val);
|
||||||
|
props.calFormState.form.FormulaCode = expression;
|
||||||
|
props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// function handleInput(e) {
|
||||||
|
// expression = e.target.value;
|
||||||
|
// cursorPosition = e.target.selectionStart;
|
||||||
|
// formState.form.FormulaCode = expression;
|
||||||
|
// formState.validateField?.('FormulaCode', expression, false);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function handleClick(e) {
|
||||||
|
// cursorPosition = e.target.selectionStart;
|
||||||
|
// }
|
||||||
|
|
||||||
|
function handleContainerClick(e) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const text = expression;
|
||||||
|
const charWidth = 8.5;
|
||||||
|
const padding = 12;
|
||||||
|
const clickX = e.clientX - rect.left - padding;
|
||||||
|
let newPos = Math.floor(clickX / charWidth);
|
||||||
|
newPos = Math.max(0, Math.min(newPos, text.length));
|
||||||
|
cursorPosition = newPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorLeft() {
|
||||||
|
if (cursorPosition > 0) {
|
||||||
|
cursorPosition -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorRight() {
|
||||||
|
if (cursorPosition < expression.length) {
|
||||||
|
cursorPosition += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChar() {
|
||||||
|
if (cursorPosition > 0) {
|
||||||
|
const before = expression.slice(0, cursorPosition - 1);
|
||||||
|
const after = expression.slice(cursorPosition);
|
||||||
|
expression = before + after;
|
||||||
|
cursorPosition -= 1;
|
||||||
|
props.calFormState.form.FormulaCode = expression;
|
||||||
|
props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExpression() {
|
||||||
|
expression = '';
|
||||||
|
cursorPosition = 0;
|
||||||
|
props.calFormState.form.FormulaCode = expression;
|
||||||
|
props.calFormState.validateField?.('FormulaCode', expression, false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 w-full">
|
||||||
|
<DictionaryFormRenderer
|
||||||
|
formState={props.calFormState}
|
||||||
|
formFields={props.testCalFormFields}
|
||||||
|
customval={customval}
|
||||||
|
operators={operators}
|
||||||
|
logicalop={logicalop}
|
||||||
|
comparisonop={comparisonop}
|
||||||
|
expression={expression}
|
||||||
|
cursorPosition={cursorPosition}
|
||||||
|
onUnselectAll={unselectAll}
|
||||||
|
onGetErrorStatus={getErrorStatus}
|
||||||
|
onAddOperator={addOperator}
|
||||||
|
onAddValue={addValue}
|
||||||
|
onHandleContainerClick={handleContainerClick}
|
||||||
|
onMoveCursorLeft={moveCursorLeft}
|
||||||
|
onMoveCursorRight={moveCursorRight}
|
||||||
|
onDeleteChar={deleteChar}
|
||||||
|
onClearExpression={clearExpression}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import * as Select from '$lib/components/ui/select/index.js';
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||||
import { Toggle } from '$lib/components/ui/toggle/index.js';
|
import { Toggle } from '$lib/components/ui/toggle/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
@ -17,6 +18,7 @@
|
|||||||
import DeleteIcon from '@lucide/svelte/icons/delete';
|
import DeleteIcon from '@lucide/svelte/icons/delete';
|
||||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
formState,
|
formState,
|
||||||
@ -32,23 +34,33 @@
|
|||||||
handleRefTypeChange,
|
handleRefTypeChange,
|
||||||
onAddMember,
|
onAddMember,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
operators,
|
//starthere
|
||||||
expression,
|
tokens = [],
|
||||||
cursorPosition,
|
cursorIndex = 0,
|
||||||
onUnselectAll,
|
onUnselectAll,
|
||||||
onGetErrorStatus,
|
onGetErrorStatus,
|
||||||
onAddOperator,
|
onAddLiteral,
|
||||||
onAddValue,
|
onAddNewline,
|
||||||
onHandleContainerClick,
|
onSetCursor,
|
||||||
|
operators,
|
||||||
|
logicalop,
|
||||||
|
comparisonop,
|
||||||
onMoveCursorLeft,
|
onMoveCursorLeft,
|
||||||
onMoveCursorRight,
|
onMoveCursorRight,
|
||||||
onDeleteChar,
|
onDeleteChar,
|
||||||
onClearExpression,
|
onClearExpression,
|
||||||
|
onAddOperator,
|
||||||
|
onAddValue,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
let searchQuery = $state({});
|
let searchQuery = $state({});
|
||||||
let dropdownOpen = $state({});
|
let dropdownOpen = $state({});
|
||||||
|
|
||||||
|
let numberPopoverOpen = $state(false);
|
||||||
|
let stringPopoverOpen = $state(false);
|
||||||
|
let stringLiteralInput = $state('');
|
||||||
|
let numberLiteralInput = $state(null);
|
||||||
|
|
||||||
function getFilteredOptions(key) {
|
function getFilteredOptions(key) {
|
||||||
const query = searchQuery[key] || '';
|
const query = searchQuery[key] || '';
|
||||||
@ -88,6 +100,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
initializeDefaultValues();
|
initializeDefaultValues();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expressionString = $derived(
|
||||||
|
tokens.map(t => t.type === 'newline' ? '\n' : t.value).join(' ').replace(/ \n /g, '\n')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Fieldset({
|
{#snippet Fieldset({
|
||||||
@ -285,7 +301,7 @@
|
|||||||
formState.form[key] = selectedObjects;
|
formState.form[key] = selectedObjects;
|
||||||
if (validateOn?.includes('input')) {
|
if (validateOn?.includes('input')) {
|
||||||
formState.validateField?.(key, selectedObjects, false);
|
formState.validateField?.(key, selectedObjects, false);
|
||||||
formState.validateField?.('FormulaCode', expression, false);
|
formState.validateField?.('FormulaCode', formState.form.FormulaCode ?? '', false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -470,69 +486,246 @@
|
|||||||
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorLeft}>
|
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorLeft}>
|
||||||
<MoveLeftIcon class="w-4 h-4" />
|
<MoveLeftIcon class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="relative flex-1">
|
<div
|
||||||
<button
|
class="relative flex-1 min-h-[2rem] rounded-md border bg-background px-3 py-2 font-mono text-sm cursor-text focus-within:ring-1 focus-within:ring-ring"
|
||||||
class="flex flex-1 h-9 w-full min-w-0 items-center rounded-md border bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
|
role="textbox"
|
||||||
role="textbox"
|
tabindex="0"
|
||||||
tabindex="0"
|
onkeydown={(e) => {
|
||||||
onclick={onHandleContainerClick}
|
if (e.key === 'Backspace') { e.preventDefault(); onDeleteChar(); }
|
||||||
>
|
if (e.key === 'ArrowLeft') onMoveCursorLeft();
|
||||||
{#each expression.split('') as char, i}
|
if (e.key === 'ArrowRight') onMoveCursorRight();
|
||||||
{#if i === cursorPosition}
|
}}
|
||||||
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
|
>
|
||||||
|
{#if tokens.length === 0}
|
||||||
|
<span class="text-muted-foreground text-xs italic">Click buttons below to build formula...</span>
|
||||||
|
{:else}
|
||||||
|
<!-- Split tokens into lines -->
|
||||||
|
{@const lines = (() => {
|
||||||
|
const result = [[]];
|
||||||
|
tokens.forEach((tok, idx) => {
|
||||||
|
if (tok.type === 'newline') result.push([]);
|
||||||
|
else result[result.length - 1].push({ tok, idx });
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})()}
|
||||||
|
{#each lines as line, lineIdx}
|
||||||
|
<div class="flex flex-wrap items-center gap-1 min-h-[28px] {lineIdx > 0 ? 'mt-1 pt-1 border-t border-dashed border-border' : ''}">
|
||||||
|
{#each line as { tok, idx }}
|
||||||
|
{#if cursorIndex === idx}
|
||||||
|
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="">{char}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border transition-colors "
|
||||||
|
onclick={() => onSetCursor(idx + 1)}
|
||||||
|
title="Click to move cursor here"
|
||||||
|
>
|
||||||
|
{tok.value}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if cursorPosition === expression.length}
|
{#if line.length > 0 && cursorIndex === line[line.length - 1].idx + 1}
|
||||||
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
|
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
|
{#if cursorIndex === tokens.length && (tokens.length === 0 || tokens[tokens.length - 1].type === 'newline')}
|
||||||
|
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded mt-1"></span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorRight}>
|
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorRight}>
|
||||||
<MoveRightIcon class="w-4 h-4" />
|
<MoveRightIcon class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" size="icon" onclick={onDeleteChar}>
|
<Button type="button" variant="outline" size="icon" onclick={onDeleteChar}>
|
||||||
<DeleteIcon class="w-4 h-4" />
|
<DeleteIcon class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" onclick={onClearExpression}>
|
||||||
<Button type="button" variant="outline" size="icon" onclick={onClearExpression}>
|
<BrushCleaningIcon class="w-4 h-4" />
|
||||||
<BrushCleaningIcon class="w-4 h-4" />
|
</Button>
|
||||||
</Button>
|
<Button type="button" variant="outline" size="icon" onclick={onAddNewline} title="New line">
|
||||||
|
<CornerDownLeftIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{#if formState.form.FormulaInput.length > 0}
|
{#if formState.form.FormulaInput?.length > 0}
|
||||||
<div>
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<!-- Selected Tests -->
|
||||||
<span class="text-sm font-medium">Selected Values</span>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-wrap gap-2">
|
<span class="text-sm font-medium">Selected Tests</span>
|
||||||
{#each formState.form.FormulaInput as item (item)}
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each formState.form.FormulaInput as item (item)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
onclick={() => onAddValue?.(item.value)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Literal Values -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium">Custom Values</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<!-- String literal popover -->
|
||||||
|
<Popover.Root bind:open={stringPopoverOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props: triggerProps })}
|
||||||
|
<Button
|
||||||
|
{...triggerProps}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
>
|
||||||
|
"abc"
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-64" side="bottom" align="start">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-sm font-semibold">Enter Text Value</p>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder='e.g. F, POS, NEG'
|
||||||
|
bind:value={stringLiteralInput}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && stringLiteralInput.trim()) {
|
||||||
|
onAddLiteral(`"${stringLiteralInput.trim()}"`);
|
||||||
|
stringLiteralInput = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
class="h-auto w-auto p-2"
|
disabled={!stringLiteralInput.trim()}
|
||||||
onclick={() => onAddValue?.(item.value)}
|
onclick={() => {
|
||||||
|
onAddLiteral(`"${stringLiteralInput.trim()}"`);
|
||||||
|
stringLiteralInput = '';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.value}
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-sm font-medium">Operators</span>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each operators as op}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onclick={() => onAddOperator?.(op)}
|
|
||||||
>
|
|
||||||
{op}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Popover.Content>
|
||||||
</div>
|
</Popover.Root>
|
||||||
{/if}
|
|
||||||
|
<!-- Number literal popover -->
|
||||||
|
<Popover.Root bind:open={numberPopoverOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props: triggerProps })}
|
||||||
|
<Button
|
||||||
|
{...triggerProps}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
>
|
||||||
|
123
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-64" side="bottom" align="start">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-sm font-semibold">Enter Number Value</p>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder='e.g. 142'
|
||||||
|
bind:value={numberLiteralInput}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && numberLiteralInput != null && !isNaN(numberLiteralInput)) {
|
||||||
|
onAddLiteral(String(numberLiteralInput));
|
||||||
|
numberLiteralInput = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={numberLiteralInput == null || isNaN(numberLiteralInput)}
|
||||||
|
onclick={() => {
|
||||||
|
onAddLiteral(String(numberLiteralInput));
|
||||||
|
numberLiteralInput = null;
|
||||||
|
numberPopoverOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logical Operators (Keywords) -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium">Logical Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each logicalop as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comparison Operators -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium">Comparison Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each comparisonop as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Math Operators -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium">Math Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each operators as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
{#if tokens.length > 0}
|
||||||
|
<div class="section">
|
||||||
|
<label class="section-label">Preview</label>
|
||||||
|
<pre class="expression-preview">{expressionString}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if type === 'members'}
|
{:else if type === 'members'}
|
||||||
{@const filteredOptions = getFilteredOptions(key)}
|
{@const filteredOptions = getFilteredOptions(key)}
|
||||||
@ -622,7 +815,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
|
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
|
||||||
{@const inputStatus = onGetErrorStatus?.(expression)}
|
{@const inputStatus = onGetErrorStatus?.()}
|
||||||
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm text-destructive">
|
<div class="flex items-center gap-2 text-sm text-destructive">
|
||||||
<span>Must included :</span>
|
<span>Must included :</span>
|
||||||
|
|||||||
@ -0,0 +1,770 @@
|
|||||||
|
<script>
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import { Toggle } from '$lib/components/ui/toggle/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Spinner } from '$lib/components/ui/spinner/index.js';
|
||||||
|
import ReusableCalendar from '$lib/components/reusable/reusable-calendar.svelte';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import * as InputGroup from '$lib/components/ui/input-group/index.js';
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
import MoveLeftIcon from '@lucide/svelte/icons/move-left';
|
||||||
|
import MoveRightIcon from '@lucide/svelte/icons/move-right';
|
||||||
|
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
|
||||||
|
import DeleteIcon from '@lucide/svelte/icons/delete';
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
|
||||||
|
|
||||||
|
let {
|
||||||
|
formState,
|
||||||
|
formFields,
|
||||||
|
mode = 'create',
|
||||||
|
disabledResultTypes = [],
|
||||||
|
disabledReferenceTypes = [],
|
||||||
|
disabledSign = false,
|
||||||
|
joinFields = $bindable(),
|
||||||
|
hiddenFields,
|
||||||
|
handleTestTypeChange,
|
||||||
|
handleResultTypeChange,
|
||||||
|
handleRefTypeChange,
|
||||||
|
onAddMember,
|
||||||
|
onRemoveMember,
|
||||||
|
customval,
|
||||||
|
operators,
|
||||||
|
logicalop,
|
||||||
|
comparisonop,
|
||||||
|
expression,
|
||||||
|
cursorPosition,
|
||||||
|
onUnselectAll,
|
||||||
|
onGetErrorStatus,
|
||||||
|
onAddOperator,
|
||||||
|
onAddValue,
|
||||||
|
onHandleContainerClick,
|
||||||
|
onMoveCursorLeft,
|
||||||
|
onMoveCursorRight,
|
||||||
|
onDeleteChar,
|
||||||
|
onClearExpression,
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
|
let searchQuery = $state({});
|
||||||
|
let dropdownOpen = $state({});
|
||||||
|
|
||||||
|
function getFilteredOptions(key) {
|
||||||
|
const query = searchQuery[key] || '';
|
||||||
|
if (!query) return formState.selectOptions?.[key] ?? [];
|
||||||
|
|
||||||
|
return (formState.selectOptions?.[key] ?? []).filter((opt) =>
|
||||||
|
opt.label.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeDefaultValues() {
|
||||||
|
for (const group of formFields) {
|
||||||
|
for (const row of group.rows) {
|
||||||
|
for (const col of row.columns) {
|
||||||
|
if (col.type === 'group') {
|
||||||
|
for (const child of col.columns) {
|
||||||
|
await handleDefaultValue(child);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await handleDefaultValue(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDefaultValue(field) {
|
||||||
|
if (!field.defaultValue || !field.optionsEndpoint) return;
|
||||||
|
|
||||||
|
await formState.fetchOptions(field, formState.form);
|
||||||
|
|
||||||
|
if (!formState.form[field.key]) {
|
||||||
|
formState.form[field.key] = field.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
initializeDefaultValues();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fieldset({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
type,
|
||||||
|
optionsEndpoint,
|
||||||
|
options,
|
||||||
|
validateOn,
|
||||||
|
dependsOn,
|
||||||
|
endpointParamKey,
|
||||||
|
valueKey,
|
||||||
|
labelKey,
|
||||||
|
txtKey
|
||||||
|
})}
|
||||||
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
|
<div class="flex justify-between items-center w-full">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{#if required}
|
||||||
|
<span class="text-destructive text-xl leading-none h-3.5">*</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex flex-col items-center w-full">
|
||||||
|
{#if type === 'text'}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => {
|
||||||
|
if (validateOn?.includes('blur')) {
|
||||||
|
validateFieldAsync(key, mode, originalData?.[key]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readonly={key === 'NumRefType' || key === 'TxtRefType' || key === 'Level'}
|
||||||
|
/>
|
||||||
|
{:else if type === 'email'}
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => {
|
||||||
|
if (validateOn?.includes('blur')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if type === 'number'}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => {
|
||||||
|
if (validateOn?.includes('blur')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => ['e', 'E', '+', '-'].includes(e.key) && e.preventDefault()}
|
||||||
|
/>
|
||||||
|
{:else if type === 'textarea'}
|
||||||
|
<Textarea
|
||||||
|
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => {
|
||||||
|
if (validateOn?.includes('blur')) {
|
||||||
|
formState.validateField(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
/>
|
||||||
|
{:else if type === 'select'}
|
||||||
|
{@const selectedLabel =
|
||||||
|
formState.selectOptions?.[key]?.find((opt) => opt.value === formState.form[key])?.label ||
|
||||||
|
'Choose'}
|
||||||
|
{@const filteredOptions = getFilteredOptions(key)}
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
formState.form[key] = val;
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField?.(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
if (key === 'TestType') {
|
||||||
|
handleTestTypeChange(val);
|
||||||
|
}
|
||||||
|
if (key === 'Province') {
|
||||||
|
formState.form.City = '';
|
||||||
|
if (formState.selectOptions) {
|
||||||
|
formState.selectOptions.City = [];
|
||||||
|
}
|
||||||
|
if (formState.lastFetched) {
|
||||||
|
formState.lastFetched.City = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === 'ResultType') {
|
||||||
|
handleResultTypeChange(val);
|
||||||
|
}
|
||||||
|
if (key === 'RefType') {
|
||||||
|
handleRefTypeChange(val);
|
||||||
|
}
|
||||||
|
if (key === 'HostType') {
|
||||||
|
formState.form.HostID = '';
|
||||||
|
formState.form.HostTestCode = '';
|
||||||
|
formState.form.HostTestName = '';
|
||||||
|
formState.selectOptions.HostTestCode = [];
|
||||||
|
formState.selectOptions.HostTestName = [];
|
||||||
|
}
|
||||||
|
if (key === 'HostID') {
|
||||||
|
formState.form.HostTestCode = '';
|
||||||
|
formState.form.HostTestName = '';
|
||||||
|
formState.selectOptions.HostTestCode = [];
|
||||||
|
formState.selectOptions.HostTestName = [];
|
||||||
|
}
|
||||||
|
if (key === 'ClientType') {
|
||||||
|
formState.form.ClientID = '';
|
||||||
|
formState.form.ClientTestCode = '';
|
||||||
|
formState.form.ClientTestName = '';
|
||||||
|
formState.selectOptions.ClientTestCode = [];
|
||||||
|
formState.selectOptions.ClientTestName = [];
|
||||||
|
}
|
||||||
|
if (key === 'ClientID') {
|
||||||
|
formState.form.ClientTestCode = '';
|
||||||
|
formState.form.ClientTestName = '';
|
||||||
|
formState.selectOptions.ClientTestCode = [];
|
||||||
|
formState.selectOptions.ClientTestName = [];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open && optionsEndpoint) {
|
||||||
|
formState.fetchOptions?.(
|
||||||
|
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
|
||||||
|
formState.form
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full truncate">
|
||||||
|
{selectedLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<div class="p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
bind:value={searchQuery[key]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if formState.loadingOptions?.[key]}
|
||||||
|
<Select.Item disabled value="loading">Loading...</Select.Item>
|
||||||
|
{:else}
|
||||||
|
{#if !required}
|
||||||
|
<Select.Item value="">- None -</Select.Item>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as option}
|
||||||
|
<Select.Item
|
||||||
|
value={option.value}
|
||||||
|
disabled={(key === 'ResultType' && disabledResultTypes.includes(option.value)) ||
|
||||||
|
(key === 'RefType' && disabledReferenceTypes.includes(option.value))}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{:else if type === 'selectmultiple'}
|
||||||
|
{@const filteredOptions = getFilteredOptions(key)}
|
||||||
|
{@const currentValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
|
||||||
|
<Select.Root
|
||||||
|
type="multiple"
|
||||||
|
value={currentValues.map((item) => item.value)}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const selectedObjects = (formState.selectOptions?.[key] ?? []).filter((opt) =>
|
||||||
|
val.includes(opt.value)
|
||||||
|
);
|
||||||
|
formState.form[key] = selectedObjects;
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField?.(key, selectedObjects, false);
|
||||||
|
formState.validateField?.('FormulaCode', expression, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open && optionsEndpoint) {
|
||||||
|
formState.fetchOptions?.(
|
||||||
|
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
|
||||||
|
formState.form
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{currentValues.length
|
||||||
|
? (formState.selectOptions?.[key] ?? [])
|
||||||
|
.filter((o) => currentValues.some((f) => f.value === o.value))
|
||||||
|
.map((o) => o.label)
|
||||||
|
.join(', ')
|
||||||
|
: 'Choose'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<div class="p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
bind:value={searchQuery[key]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select.Group>
|
||||||
|
{#if formState.loadingOptions?.[key]}
|
||||||
|
<div class="p-2 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
{#if currentValues.length > 0}
|
||||||
|
<Select.Separator />
|
||||||
|
<button
|
||||||
|
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onclick={() => onUnselectAll?.(key)}
|
||||||
|
>
|
||||||
|
Unselect All
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as opt (opt.value)}
|
||||||
|
<Select.Item value={opt.value} label={opt.label}>
|
||||||
|
{opt.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{:else if type === 'date'}
|
||||||
|
<ReusableCalendar
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
parentFunction={(dateStr) => {
|
||||||
|
formState.form[key] = dateStr;
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField(key, dateStr, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if type === 'signvalue'}
|
||||||
|
<InputGroup.Root>
|
||||||
|
<InputGroup.Input
|
||||||
|
placeholder="Type here"
|
||||||
|
bind:value={formState.form[txtKey]}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField('Low', formState.form[txtKey]);
|
||||||
|
formState.validateField('LowSign');
|
||||||
|
formState.validateField('High', formState.form[txtKey]);
|
||||||
|
formState.validateField('HighSign');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon align="inline-start">
|
||||||
|
<DropdownMenu.Root
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
dropdownOpen[key] = open;
|
||||||
|
if (open && optionsEndpoint) {
|
||||||
|
formState.fetchOptions?.(
|
||||||
|
{ key, optionsEndpoint, valueKey, labelKey },
|
||||||
|
formState.form
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<InputGroup.Button {...props} variant="ghost" disabled={disabledSign}>
|
||||||
|
{formState.selectOptions?.[key]?.find(
|
||||||
|
(opt) => opt.value === formState.form[key]
|
||||||
|
)?.label || 'Choose'}
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</InputGroup.Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="start">
|
||||||
|
{#if formState.loadingOptions?.[key]}
|
||||||
|
<DropdownMenu.Item disabled>Loading...</DropdownMenu.Item>
|
||||||
|
{:else}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
formState.form[key] = '';
|
||||||
|
dropdownOpen[key] = false;
|
||||||
|
formState.validateField('LowSign');
|
||||||
|
formState.validateField('HighSign');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
- None -
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{#each formState.selectOptions?.[key] || [] as option}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => {
|
||||||
|
formState.form[key] = option.value;
|
||||||
|
dropdownOpen[key] = false;
|
||||||
|
}}
|
||||||
|
onSelect={() => {
|
||||||
|
formState.form[key] = option.value;
|
||||||
|
formState.validateField('LowSign');
|
||||||
|
formState.validateField('HighSign');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup.Root>
|
||||||
|
{:else if type === 'agejoin'}
|
||||||
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
<InputGroup.Root>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="number"
|
||||||
|
bind:value={joinFields[key].YY}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField('AgeStart');
|
||||||
|
formState.validateField('AgeEnd');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon align="inline-end">
|
||||||
|
<InputGroup.Text>Year</InputGroup.Text>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup.Root>
|
||||||
|
<InputGroup.Root>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="number"
|
||||||
|
bind:value={joinFields[key].MM}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField('AgeStart');
|
||||||
|
formState.validateField('AgeEnd');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon align="inline-end">
|
||||||
|
<InputGroup.Text>Month</InputGroup.Text>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup.Root>
|
||||||
|
<InputGroup.Root>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="number"
|
||||||
|
bind:value={joinFields[key].DD}
|
||||||
|
oninput={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField('AgeStart');
|
||||||
|
formState.validateField('AgeEnd');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon align="inline-end">
|
||||||
|
<InputGroup.Text>Day</InputGroup.Text>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup.Root>
|
||||||
|
</div>
|
||||||
|
{:else if type === 'formulabuilder'}
|
||||||
|
<div class="flex flex-col gap-8 w-full">
|
||||||
|
<div class="flex gap-1 w-full">
|
||||||
|
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorLeft}>
|
||||||
|
<MoveLeftIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<button
|
||||||
|
class="flex flex-1 h-27 w-full min-w-0 items-center rounded-md border bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
|
||||||
|
role="textbox"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={onHandleContainerClick}
|
||||||
|
>
|
||||||
|
{#each expression.split('') as char, i}
|
||||||
|
{#if i === cursorPosition}
|
||||||
|
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="">{char}</span>
|
||||||
|
{/each}
|
||||||
|
{#if cursorPosition === expression.length}
|
||||||
|
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorRight}>
|
||||||
|
<MoveRightIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" onclick={onDeleteChar}>
|
||||||
|
<DeleteIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" onclick={onClearExpression}>
|
||||||
|
<BrushCleaningIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="icon">
|
||||||
|
<CornerDownLeftIcon class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if formState.form.FormulaInput.length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Selected Tests</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each formState.form.FormulaInput as item (item)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
onclick={() => onAddValue?.(item.value)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Possible Values (from selected tests)</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Custom Values</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each customval as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Logical Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each logicalop as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-auto w-auto p-2"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Comparison Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each comparisonop as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium">Math Operators</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each operators as op}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => onAddOperator?.(op)}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if type === 'members'}
|
||||||
|
{@const filteredOptions = getFilteredOptions(key)}
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
{#each formState.form.Members as member, index (member.id)}
|
||||||
|
{@const selectedLabel =
|
||||||
|
formState.selectOptions?.[key]?.find((opt) => opt.value === member.value)?.label ||
|
||||||
|
'Choose'}
|
||||||
|
<div class="flex gap-1 w-full">
|
||||||
|
<Button type="button" variant="outline" size="icon" disabled>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={member.value}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open && optionsEndpoint) {
|
||||||
|
formState.fetchOptions?.(
|
||||||
|
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
|
||||||
|
formState.form
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onValueChange={() => {
|
||||||
|
if (validateOn?.includes('input')) {
|
||||||
|
formState.validateField?.(key, formState.form[key], false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{selectedLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<div class="p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
bind:value={searchQuery[key]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if formState.loadingOptions?.[key]}
|
||||||
|
<Select.Item disabled value="loading">Loading...</Select.Item>
|
||||||
|
{:else}
|
||||||
|
{#if !required}
|
||||||
|
<Select.Item value="">- None -</Select.Item>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as option}
|
||||||
|
<Select.Item value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => onRemoveMember(member.id)}
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button variant="outline" onclick={onAddMember}>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formState.form[key]}
|
||||||
|
placeholder="Custom field type: {type}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class={`absolute min-h-[1rem] w-full ${key === 'Members' ? 'right-0 -top-5 text-right' : 'top-10'}`}>
|
||||||
|
{#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])}
|
||||||
|
{@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]}
|
||||||
|
|
||||||
|
<div class="text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
|
||||||
|
{@const inputStatus = onGetErrorStatus?.(expression)}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<span>Must included :</span>
|
||||||
|
|
||||||
|
<div class="flex gap-1 flex-wrap">
|
||||||
|
{#each inputStatus as item (item.value)}
|
||||||
|
<Badge variant={item.done ? 'default' : 'destructive'}>
|
||||||
|
{item.value}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="p-2 space-y-6">
|
||||||
|
{#each formFields as group}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if group.title}
|
||||||
|
<div class="text-md 2xl:text-lg font-semibold italic">
|
||||||
|
<span class="border-b-2 border-primary">{group.title}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each group.rows as row}
|
||||||
|
{@const visibleColumns = row.columns.filter((col) => !hiddenFields?.includes(col.key))}
|
||||||
|
|
||||||
|
{#if visibleColumns.length > 0}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
|
||||||
|
class:md:grid-cols-1={visibleColumns.length === 1 &&
|
||||||
|
visibleColumns[0].fullWidth !== false}
|
||||||
|
class:md:grid-cols-2={visibleColumns.length === 2 ||
|
||||||
|
(visibleColumns.length === 1 && visibleColumns[0].fullWidth === false)}
|
||||||
|
class:md:grid-cols-3={visibleColumns.length === 3}
|
||||||
|
>
|
||||||
|
{#each visibleColumns as col}
|
||||||
|
{#if col.type === 'group'}
|
||||||
|
{@const visibleChildColumns = col.columns.filter(
|
||||||
|
(child) => !hiddenFields?.includes(child.key)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{#if visibleChildColumns.length > 0}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 gap-6 md:gap-2"
|
||||||
|
class:md:grid-cols-1={visibleChildColumns.length === 1 &&
|
||||||
|
visibleChildColumns[0].fullWidth !== false}
|
||||||
|
class:md:grid-cols-2={visibleChildColumns.length === 2 ||
|
||||||
|
(visibleChildColumns.length === 1 &&
|
||||||
|
visibleChildColumns[0].fullWidth === false)}
|
||||||
|
class:md:grid-cols-3={visibleChildColumns.length === 3}
|
||||||
|
>
|
||||||
|
{#each visibleChildColumns as child}
|
||||||
|
{@render Fieldset(child)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{@render Fieldset(col)}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-cursor {
|
||||||
|
animation: cursor-blink 1s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
x
Reference in New Issue
Block a user