UNPKG

vira

Version:

A simple and highly versatile design system using element-vir.

467 lines (433 loc) 17.3 kB
import { assertWrap } from '@augment-vir/assert'; import { randomString } from '@augment-vir/common'; import { extractEventTarget } from '@augment-vir/web'; import { attributes, css, defineElementEvent, html, ifDefined, listen, nothing, onResize, renderIf, } from 'element-vir'; import { CloseX24Icon } from '../icons/icon-svgs/24/close-x-24.icon.js'; import { EyeClosed24Icon, EyeOpen24Icon } from '../icons/index.js'; import { createFocusStyles } from '../styles/focus.js'; import { viraFontCssVars } from '../styles/font.js'; import { viraFormCssVars } from '../styles/form-styles.js'; import { ViraSize, viraSizeHeights } from '../styles/form-variants.js'; import { noUserSelect, viraAnimationDurations, viraDisabledStyles } from '../styles/index.js'; import { noNativeFormStyles } from '../styles/native-styles.js'; import { defineViraElement } from '../util/define-vira-element.js'; import { filterTextInputValue, textInputListener, } from '../util/shared-text-input-logic.js'; import { ViraIcon } from './vira-icon.element.js'; export * from '../util/shared-text-input-logic.js'; /** * Input types for {@link ViraInput}. * * @category Internal */ export var ViraInputType; (function (ViraInputType) { ViraInputType["Default"] = "text"; ViraInputType["Password"] = "password"; ViraInputType["Email"] = "email"; ViraInputType["Number"] = "number"; })(ViraInputType || (ViraInputType = {})); /** * A single line input element with all listeners properly attached. Multiple types are allowed with * {@link ViraInputType}. * * @category Input * @category Elements * @see https://electrovir.github.io/vira/book/elements/vira-input */ export const ViraInput = defineViraElement()({ tagName: 'vira-input', cssVars: { 'vira-input-padding-horizontal': '10px', 'vira-input-padding-vertical': '5px', }, styles: ({ hostClasses, cssVars }) => { return css ` :host { position: relative; display: inline-flex; width: 224px; box-sizing: border-box; color: ${viraFormCssVars['vira-form-foreground-color'].value}; } label { display: flex; flex-direction: column; justify-content: flex-start; gap: 2px; width: 100%; max-width: 100%; cursor: text; & .input-label { font-weight: ${viraFormCssVars['vira-form-label-font-weight'].value}; text-align: left; flex-shrink: 0; flex-wrap: wrap; } } ${hostClasses['vira-input-fit-text'].selector} { width: unset; } ${hostClasses['vira-input-fit-text'].selector} input { flex-grow: 0; } ${hostClasses['vira-input-fit-text'].selector} input.has-value { /* Account for weird Safari <input> behavior with text alignment and size. so we don't lose a pixel on the left side. Only apply this when <input> has a value, otherwise externally-set width and a placeholder input will cause the text selector bar to initially be in the center of the element. */ text-align: center; } ${hostClasses['vira-input-fit-text'].selector} .size-span { ${noNativeFormStyles}; font-family: inherit; display: inline-block; font-size: inherit; line-height: inherit; box-sizing: border-box; position: absolute; opacity: 0; visibility: hidden; pointer-events: none; z-index: -1; width: min-content; ${noUserSelect}; vertical-align: middle; max-height: 100%; } ${hostClasses['vira-input-clear-button-shown'].selector} .input-wrapper { padding-right: 4px; } pre { ${noNativeFormStyles}; font: inherit; /* Leave at least a few pixels for the cursor bar when there is no text at all. This also accounts for a weird Safari <input> behavior where the text moves around if it's not given a tiny bit of padding. */ padding-left: 2px; display: block; } .border-style { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: ${viraFormCssVars['vira-form-radius'].value}; z-index: 0; pointer-events: none; } .wrapper-border { top: -1px; left: -1px; border: 1px solid ${viraFormCssVars['vira-form-border-color'].value}; } .input-wrapper { ${noNativeFormStyles}; max-width: 100%; flex-grow: 1; display: inline-flex; min-height: ${viraSizeHeights[ViraSize.Medium]}px; box-sizing: border-box; align-items: center; position: relative; padding: 0 ${cssVars['vira-input-padding-horizontal'].value}; border-radius: ${viraFormCssVars['vira-form-radius'].value}; background-color: ${viraFormCssVars['vira-form-background-color'].value}; /* Border colors are actually applied via the .wrapper-border class. However, we must apply a border here still so that it takes up space. */ border: 1px solid transparent; gap: 4px; cursor: text; } .left-side-icon { margin-right: calc(${cssVars['vira-input-padding-horizontal'].value} - 4px); } .readonly-value { overflow-wrap: anywhere; } input { ${noNativeFormStyles}; cursor: text; margin: ${cssVars['vira-input-padding-vertical'].value} 0; flex-grow: 1; max-width: 100%; text-align: inherit; /* fix input element not shrinking by default */ width: 0; text-overflow: ellipsis; box-sizing: border-box; overflow: hidden; outline: none; &:focus:focus-visible:not([disabled]) ~ .focus-border { ${createFocusStyles({ elementBorderSize: '1px', noNesting: true, })} } } ::selection { background: ${viraFormCssVars['vira-form-text-selection-color'] .value}; /* WebKit/Blink Browsers */ } ::-moz-selection { background: ${viraFormCssVars['vira-form-text-selection-color'] .value}; /* Gecko Browsers */ } input:placeholder-shown { text-overflow: ellipsis; overflow: hidden; } input::placeholder { color: ${viraFormCssVars['vira-form-placeholder-color'].value}; } .suffix { font-weight: ${viraFontCssVars['vira-font-weight-bold'].value}; ${noUserSelect}; } button { ${noNativeFormStyles}; cursor: pointer; display: flex; transition: color ${viraAnimationDurations['vira-interaction-animation-duration'].value}; } .clear-x-button, .show-password-button { color: ${viraFormCssVars['vira-form-placeholder-color'].value}; } .clear-x-button:hover { color: ${viraFormCssVars['vira-form-error-color'].value}; } .clear-x-button:active { color: ${viraFormCssVars['vira-form-error-active-color'].value}; } .show-password-button:hover { color: ${viraFormCssVars['vira-form-accent-primary-color'].value}; } .show-password-button:active { color: ${viraFormCssVars['vira-form-accent-primary-active-color'].value}; } ${hostClasses['vira-input-error'].selector} { & .wrapper-border { border-color: ${viraFormCssVars['vira-form-error-color'].value}; } } ${hostClasses['vira-input-disabled'].selector} { cursor: not-allowed; & * { cursor: not-allowed; } & > * { ${viraDisabledStyles}; } & .show-password-button { pointer-events: none; } & .focus-border { display: none; } } `; }, events: { /** * Fires whenever a user input created a new value. Does not fire if all input letters are * filtered out due to input restrictions. */ valueChange: defineElementEvent(), /** * Fires when inputs are blocked. Useful for showing warnings or error messages to inform * the user why their input did not propagate if it was blocked. This does not fire for text * that was blocked out of programmatic "value" property assignments. */ inputBlocked: defineElementEvent(), }, state() { return { forcedInputWidth: 0, showPassword: false, /** * Used to couple the label and input together. This is not applied if no label is * provided. */ randomId: randomString(32), }; }, hostClasses: { 'vira-input-disabled': ({ inputs }) => !!inputs.disabled, 'vira-input-fit-text': ({ inputs }) => !!inputs.fitText, 'vira-input-clear-button-shown': ({ inputs }) => !!inputs.showClearButton, 'vira-input-error': ({ inputs }) => !!inputs.hasError, }, render: ({ inputs, dispatch, state, updateState, events, host }) => { const { filtered: filteredValue } = filterTextInputValue({ value: inputs.value, allowed: inputs.allowedInputs, blocked: inputs.blockedInputs, }); if (inputs.isReadonly) { const readonlyValueTemplate = html ` <span class="readonly-value">${filteredValue}</span> `; if (inputs.label) { return html ` <label> <span class="input-label">${inputs.label}</span> ${readonlyValueTemplate} </label> `; } else { return readonlyValueTemplate; } } const iconTemplate = inputs.icon ? html ` <${ViraIcon.assign({ icon: inputs.icon, })} class="left-side-icon" ></${ViraIcon}> ` : nothing; const forcedInputWidthStyles = inputs.fitText ? css ` width: ${state.forcedInputWidth}px; ` : nothing; const mousedownListener = listen('mousedown', (event) => { const eventTarget = extractEventTarget(event, HTMLElement, { useOriginalTarget: true, }); const inputElement = assertWrap.instanceOf(host.shadowRoot.querySelector('input'), HTMLInputElement); if (eventTarget !== inputElement) { event.preventDefault(); inputElement.focus(); } }); const shouldBlockBrowserHelps = inputs.disableBrowserHelps || /** * Some browsers leaks passwords with their browser helps (like Chrome with * spellchecking). */ inputs.type === ViraInputType.Password; const inputTemplate = html ` <span class="input-wrapper" ${inputs.label ? nothing : mousedownListener}> ${iconTemplate} ${renderIf(!!inputs.fitText, html ` <span class="size-span" ${onResize(({ contentRect }) => { updateState({ forcedInputWidth: contentRect.width, }); })} > <pre>${filteredValue || inputs.placeholder || nothing}</pre> </span> `)} <input id=${ifDefined(inputs.label ? state.randomId : undefined)} aria-label=${ifDefined(inputs.label || undefined)} autofocus=${false} type=${calculateEffectiveInputType(inputs.type, state.showPassword)} style=${forcedInputWidthStyles} autocomplete=${ifDefined(shouldBlockBrowserHelps ? 'off' : undefined)} autocorrect=${ifDefined(shouldBlockBrowserHelps ? 'off' : undefined)} autocapitalize=${ifDefined(shouldBlockBrowserHelps ? 'off' : undefined)} spellcheck=${ifDefined(shouldBlockBrowserHelps ? 'false' : undefined)} ?disabled=${inputs.disabled} .value=${filteredValue} ${listen('input', (event) => { textInputListener({ inputs, previousValue: filteredValue, event, inputBlockedCallback(blockedInput) { dispatch(new events.inputBlocked(blockedInput)); }, newValueCallback(newValue) { dispatch(new events.valueChange(newValue)); }, }); })} placeholder=${ifDefined(inputs.placeholder || undefined)} ${attributes(inputs.attributePassthrough)} /> ${renderIf(!!(inputs.showClearButton && inputs.value), html ` <button class="clear-x-button" title="clear" ${listen('mousedown', (event) => { event.stopImmediatePropagation(); event.preventDefault(); })} ${listen('click', () => { if (inputs.disabled) { return; } dispatch(new events.valueChange('')); })} > <${ViraIcon.assign({ icon: CloseX24Icon, })}></${ViraIcon}> </button> `)} ${renderIf(inputs.type === ViraInputType.Password, html ` <button class="show-password-button" title="show password" ${listen('mousedown', (event) => { /** Prevent focus of the input. */ event.stopImmediatePropagation(); event.preventDefault(); })} ${listen('click', () => { updateState({ showPassword: !state.showPassword, }); })} > <${ViraIcon.assign({ icon: state.showPassword ? EyeOpen24Icon : EyeClosed24Icon, })}></${ViraIcon}> </button> `)} ${renderIf(!!inputs.suffix, html ` <div class="suffix">${inputs.suffix}</div> `)} <!-- These separate style elements are necessary so that we can select them as siblings of the focused <input> element. --> <div class="border-style focus-border"></div> <div class="border-style wrapper-border"></div> </span> `; if (inputs.label) { return html ` <label for=${state.randomId} ${mousedownListener}> <span class="input-label">${inputs.label}</span> ${inputTemplate} </label> `; } else { return inputTemplate; } }, }); function calculateEffectiveInputType(type, showPassword) { if (type === ViraInputType.Password && showPassword) { return ViraInputType.Default; } return type || ViraInputType.Default; }