diff --git a/testgen/ui/components/frontend/css/shared.css b/testgen/ui/components/frontend/css/shared.css index e387b81..4955a1b 100644 --- a/testgen/ui/components/frontend/css/shared.css +++ b/testgen/ui/components/frontend/css/shared.css @@ -27,7 +27,8 @@ body { --secondary-text-color: #0000008a; --disabled-text-color: #00000042; --caption-text-color: rgba(49, 51, 63, 0.6); /* Match Streamlit's caption color */ - --border-color: rgba(0, 0, 0, .12); + --form-field-color: rgb(240, 242, 246); /* Match Streamlit's form field color */ + --border-color: rgba(0, 0, 0, .12); --tooltip-color: #333d; --dk-card-background: #fff; @@ -71,6 +72,7 @@ body { --secondary-text-color: rgba(255, 255, 255, .7); --disabled-text-color: rgba(255, 255, 255, .5); --caption-text-color: rgba(250, 250, 250, .6); /* Match Streamlit's caption color */ + --form-field-color: rgb(38, 39, 48); /* Match Streamlit's form field color */ --border-color: rgba(255, 255, 255, .25); --dk-card-background: #14181f; @@ -94,6 +96,10 @@ body { } } +.clickable { + cursor: pointer; +} + .hidden { display: none !important; } diff --git a/testgen/ui/components/frontend/js/components/checkbox.js b/testgen/ui/components/frontend/js/components/checkbox.js new file mode 100644 index 0000000..c7cf9a9 --- /dev/null +++ b/testgen/ui/components/frontend/js/components/checkbox.js @@ -0,0 +1,83 @@ +/** + * @typedef Properties + * @type {object} + * @property {string} label + * @property {boolean?} checked + * @property {function?} onChange + * @property {number?} width + */ +import van from '../van.min.js'; +import { getValue, loadStylesheet } from '../utils.js'; + +const { input, label } = van.tags; + +const Checkbox = (/** @type Properties */ props) => { + loadStylesheet('checkbox', stylesheet); + + return label( + { + class: 'flex-row fx-gap-2 clickable', + style: () => `width: ${props.width ? getValue(props.width) + 'px' : 'auto'}`, + }, + input({ + type: 'checkbox', + class: 'tg-checkbox--input clickable', + checked: props.checked, + onchange: van.derive(() => { + const onChange = props.onChange?.val ?? props.onChange; + return onChange ? (event) => onChange(event.target.checked) : null; + }), + }), + props.label, + ); +}; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replace(` +.tg-checkbox--input { + appearance: none; + box-sizing: border-box; + margin: 0; + width: 18px; + height: 18px; + border: 1px solid var(--secondary-text-color); + border-radius: 4px; + position: relative; + transition-property: border-color, background-color; + transition-duration: 0.3s; +} + +.tg-checkbox--input:focus, +.tg-checkbox--input:focus-visible { + outline: none; +} + +.tg-checkbox--input:focus-visible::before { + content: ''; + box-sizing: border-box; + position: absolute; + top: -4px; + left: -4px; + width: 24px; + height: 24px; + border: 3px solid var(--border-color); + border-radius: 7px; +} + +.tg-checkbox--input:checked { + border-color: transparent; + background-color: var(--primary-color); +} + +.tg-checkbox--input:checked::after { + position: absolute; + top: -4px; + left: -3px; + content: 'check'; + font-family: 'Material Symbols Rounded'; + font-size: 22px; + color: white; +} +`); + +export { Checkbox }; diff --git a/testgen/ui/components/frontend/js/components/input.js b/testgen/ui/components/frontend/js/components/input.js new file mode 100644 index 0000000..be2aa03 --- /dev/null +++ b/testgen/ui/components/frontend/js/components/input.js @@ -0,0 +1,104 @@ +/** + * @typedef Properties + * @type {object} + * @property {string?} label + * @property {(string | number)?} value + * @property {string?} placeholder + * @property {string?} icon + * @property {boolean?} clearable + * @property {function?} onChange + * @property {number?} width + */ +import van from '../van.min.js'; +import { debounce, getValue, loadStylesheet } from '../utils.js'; + +const { input, label, i } = van.tags; + +const Input = (/** @type Properties */ props) => { + loadStylesheet('input', stylesheet); + + const value = van.derive(() => getValue(props.value) ?? ''); + van.derive(() => { + const onChange = props.onChange?.val ?? props.onChange; + onChange?.(value.val); + }); + + return label( + { + class: 'flex-column fx-gap-1 text-caption text-capitalize tg-input--label', + style: () => `width: ${props.width ? getValue(props.width) + 'px' : 'auto'}`, + }, + props.label, + () => getValue(props.icon) ? i( + { class: 'material-symbols-rounded tg-input--icon' }, + props.icon, + ) : '', + () => getValue(props.clearable) ? i( + { + class: () => `material-symbols-rounded tg-input--clear clickable ${value.val ? '' : 'hidden'}`, + onclick: () => value.val = '', + }, + 'clear', + ) : '', + input({ + class: 'tg-input--field', + value, + placeholder: () => getValue(props.placeholder) ?? '', + oninput: debounce(event => value.val = event.target.value, 300), + }), + ); +}; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replace(` +.tg-input--label { + position: relative; +} + +.tg-input--icon { + position: absolute; + bottom: 5px; + left: 4px; + font-size: 22px; +} + +.tg-input--icon ~ .tg-input--field { + padding-left: 28px; +} + +.tg-input--clear { + position: absolute; + bottom: 6px; + right: 4px; + font-size: 20px; +} + +.tg-input--clear ~ .tg-input--field { + padding-right: 24px; +} + +.tg-input--field { + box-sizing: border-box; + width: 100%; + height: 32px; + border-radius: 8px; + border: 1px solid transparent; + transition: border-color 0.3s; + background-color: var(--form-field-color); + padding: 4px 8px; + color: var(--primary-text-color); + font-size: 14px; +} + +.tg-input--field::placeholder { + color: var(--disabled-text-color); +} + +.tg-input--field:focus, +.tg-input--field:focus-visible { + outline: none; + border-color: var(--primary-color); +} +`); + +export { Input }; diff --git a/testgen/ui/components/frontend/js/components/radio_group.js b/testgen/ui/components/frontend/js/components/radio_group.js new file mode 100644 index 0000000..0c7f5e4 --- /dev/null +++ b/testgen/ui/components/frontend/js/components/radio_group.js @@ -0,0 +1,104 @@ +/** +* @typedef Option + * @type {object} + * @property {string} label + * @property {string | number | boolean | null} value + * + * @typedef Properties + * @type {object} + * @property {string} label + * @property {Option[]} options + * @property {string | number | boolean | null} selected + * @property {function?} onChange + * @property {number?} width + */ +import van from '../van.min.js'; +import { getRandomId, getValue, loadStylesheet } from '../utils.js'; + +const { div, input, label } = van.tags; + +const RadioGroup = (/** @type Properties */ props) => { + loadStylesheet('radioGroup', stylesheet); + const groupName = getRandomId(); + + return div( + { style: () => `width: ${props.width ? getValue(props.width) + 'px' : 'auto'}` }, + div( + { class: 'text-caption text-capitalize mb-1' }, + props.label, + ), + () => div( + { class: 'flex-row fx-gap-4 tg-radio-group' }, + getValue(props.options).map(option => label( + { class: 'flex-row fx-gap-2 text-capitalize clickable' }, + input({ + type: 'radio', + name: groupName, + value: option.value, + checked: () => option.value === getValue(props.value), + onchange: van.derive(() => { + const onChange = props.onChange?.val ?? props.onChange; + return onChange ? () => onChange(option.value) : null; + }), + class: 'tg-radio-group--input', + }), + option.label, + )), + ), + ); +}; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replace(` +.tg-radio-group { + height: 32px; +} + +.tg-radio-group--input { + appearance: none; + box-sizing: border-box; + margin: 0; + width: 18px; + height: 18px; + border: 1px solid var(--secondary-text-color); + border-radius: 9px; + position: relative; + transition-property: border-color, background-color; + transition-duration: 0.3s; +} + +.tg-radio-group--input:focus, +.tg-radio-group--input:focus-visible { + outline: none; +} + +.tg-radio-group--input:focus-visible::before { + content: ''; + box-sizing: border-box; + position: absolute; + top: -4px; + left: -4px; + width: 24px; + height: 24px; + border: 3px solid var(--border-color); + border-radius: 12px; +} + +.tg-radio-group--input:checked { + border-color: var(--primary-color); +} + +.tg-radio-group--input:checked::after { + content: ''; + box-sizing: border-box; + position: absolute; + top: 3px; + left: 3px; + width: 10px; + height: 10px; + background-color: var(--primary-color); + border-radius: 5px; +} +`); + +export { RadioGroup }; diff --git a/testgen/ui/components/frontend/js/components/select.js b/testgen/ui/components/frontend/js/components/select.js index 5f4f68c..f4fe618 100644 --- a/testgen/ui/components/frontend/js/components/select.js +++ b/testgen/ui/components/frontend/js/components/select.js @@ -13,7 +13,7 @@ */ import van from '../van.min.js'; import { Streamlit } from '../streamlit.js'; -import { getValue, loadStylesheet } from '../utils.js'; +import { getRandomId, getValue, loadStylesheet } from '../utils.js'; const { div, label, option, select } = van.tags; @@ -21,7 +21,7 @@ const Select = (/** @type {Properties} */ props) => { loadStylesheet('select', stylesheet); Streamlit.setFrameHeight(); - const domId = Math.random().toString(36).substring(2); + const domId = getRandomId(); const changeHandler = props.onChange || post; return div( {class: 'tg-select'}, diff --git a/testgen/ui/components/frontend/js/utils.js b/testgen/ui/components/frontend/js/utils.js index d8d712c..b5bdc96 100644 --- a/testgen/ui/components/frontend/js/utils.js +++ b/testgen/ui/components/frontend/js/utils.js @@ -53,4 +53,20 @@ function getValue(/** @type object */ prop) { // van state or static value return prop; } -export { emitEvent, enforceElementWidth, getValue, loadStylesheet, resizeFrameHeightToElement }; +function getRandomId() { + return Math.random().toString(36).substring(2); +} + +// https://stackoverflow.com/a/75988895 +function debounce( + /** @type function */ callback, + /** @type number */ wait, +) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => callback(...args), wait); + }; +} + +export { debounce, emitEvent, enforceElementWidth, getRandomId, getValue, loadStylesheet, resizeFrameHeightToElement };