diff --git a/src/common/gui/base/LoginTextField.ts b/src/common/gui/base/LoginTextField.ts new file mode 100644 index 0000000000..c690b08fb4 --- /dev/null +++ b/src/common/gui/base/LoginTextField.ts @@ -0,0 +1,391 @@ +import m, { Children, ClassComponent, CVnode } from "mithril" +import { font_size, px, size } from "../size" +import { DefaultAnimationTime } from "../animation/Animations" +import { theme } from "../theme" +import type { MaybeTranslation } from "../../misc/LanguageViewModel" +import { lang } from "../../misc/LanguageViewModel" +import type { lazy } from "@tutao/tutanota-utils" +import { isKeyPressed, keyHandler, useKeyHandler } from "../../misc/KeyManager" +import { Keys, TabIndex } from "../../api/common/TutanotaConstants" +import { ClickHandler, getOperatingClasses } from "./GuiUtils" +import { AriaPopupType } from "../AriaUtils.js" +import { AllIcons, Icon, IconSize } from "./Icon" +import { Autocapitalize, Autocomplete, TextFieldType } from "./TextField" + +export type LoginTextFieldAttrs = { + id?: string + label: MaybeTranslation + value: string + autocompleteAs?: Autocomplete + autocapitalize?: Autocapitalize + type?: TextFieldType + hasPopup?: AriaPopupType + helpLabel?: lazy | null + alignRight?: boolean + injectionsLeft?: lazy + // only used by the BubbleTextField (-> uses old TextField) to display bubbles and out of office notification + injectionsRight?: lazy + keyHandler?: keyHandler + onDomInputCreated?: (dom: HTMLInputElement) => void + // interceptor used by the BubbleTextField to react on certain keys + onfocus?: (dom: HTMLElement, input: HTMLInputElement) => unknown + onblur?: (...args: Array) => any + maxWidth?: number + class?: string + style?: Record //Temporary, Do not use + disabled?: boolean + // Creates a dummy TextField without interactively & disabled styling + isReadOnly?: boolean + oninput?: (value: string, input: HTMLInputElement) => unknown + onclick?: ClickHandler + doShowBorder?: boolean | null + fontSize?: string + min?: number + max?: number + leadingIcon?: { + icon: AllIcons + color: string + } + + /** This is called whenever the return key is pressed; overrides keyHandler */ + onReturnKeyPressed?: () => unknown +} + +const inputMarginTop = font_size.small + size.spacing_4 + 3 + +// this is not always correct because font size can be bigger/smaller, and we ideally should take that into account +const baseLabelPosition = "-50%" +// it should fit +// compact button + 1 px border + 1 px padding to keep things centered = 32 +// 24px line-height + 12px label + some space between them = 36 + ? +const minInputHeight = 56 + +export class LoginTextField implements ClassComponent { + active: boolean + onblur: EventListener | null = null + domInput!: HTMLInputElement + _domWrapper!: HTMLElement + private _domLabel!: HTMLElement + private _domInputWrapper!: HTMLElement + private _didAutofill!: boolean + + constructor() { + this.active = false + } + + view(vnode: CVnode): Children { + const a = vnode.attrs + const maxWidth = a.maxWidth + const labelBase = !this.active && a.value === "" && !a.isReadOnly && !this._didAutofill && !a.injectionsLeft + const labelTransitionSpeed = DefaultAnimationTime / 2 + const doShowBorder = a.doShowBorder !== false + const borderWidth = 3 + const borderColor = this.active ? theme.primary : "transparent" + const borderBottomRadius = this.active ? "0px" : px(size.radius_8) + + const borderRadius = `${px(size.radius_8)} ${px(size.radius_8)} ${borderBottomRadius} ${borderBottomRadius}` + + return [ + m( + ".login-textfield.rel.overflow-hidden", + { + id: vnode.attrs.id, + oncreate: (vnode) => (this._domWrapper = vnode.dom as HTMLElement), + onclick: (e: MouseEvent) => (a.onclick ? a.onclick(e, this._domInputWrapper) : this.focus(e, a)), + "aria-haspopup": a.hasPopup, + "data-testid": `tf:${lang.getTestId(a.label)}`, + class: a.class != null ? a.class : "mt-16" + " " + getOperatingClasses(a.disabled), + style: maxWidth + ? { + maxWidth: px(maxWidth), + borderRadius, + ...a.style, + } + : { ...a.style, borderRadius }, + }, + [ + m( + "label.abs.text-ellipsis.noselect.z1.pr-4", + { + "aria-hidden": "true", + class: this.active ? "" : "" + " " + getOperatingClasses(a.disabled), + oncreate: (vnode) => { + this._domLabel = vnode.dom as HTMLElement + }, + style: { + fontSize: `${labelBase ? font_size.base : font_size.small}px`, + transform: `translateY(${labelBase ? baseLabelPosition : 0})`, + "transition-timing-function": "ease-out", + "transition-duration": `${labelTransitionSpeed}ms`, + "transition-property": "transform, font-size, top, color", + top: labelBase ? "50%" : px(size.spacing_8), + left: a.leadingIcon ? px(size.icon_24 + size.spacing_16) : 0, + "padding-left": px(size.spacing_16), + "padding-right": px(size.spacing_16), + color: !this.active ? "inherit" : theme.primary, + }, + }, + lang.getTranslationText(a.label), + ), + m(".flex.flex-column", [ + // another wrapper to fix IE 11 min-height bug https://github.com/philipwalton/flexbugs#3-min-height-on-a-flex-container-wont-apply-to-its-flex-items + m( + ".flex.items-end.flex-wrap", + { + // .flex-wrap + style: { + "min-height": px(minInputHeight), + "border-bottom": doShowBorder ? `${px(borderWidth)} solid ${borderColor}` : "", + transition: `border-bottom ${labelTransitionSpeed}ms ease-out`, + }, + }, + [ + a.leadingIcon && + m(Icon, { + size: IconSize.PX20, + icon: a.leadingIcon.icon, + style: { + fill: a.leadingIcon.color, + "align-self": "center", + "padding-left": px(16), + position: "relative", + top: px(borderWidth / 2), + }, + }), + a.injectionsLeft ? a.injectionsLeft() : null, // additional wrapper element for bubble input field. input field should always be in one line with right injections + m( + ".inputWrapper.flex-space-between.items-end", + { + style: { + minHeight: px(minInputHeight - 2), // minus padding + + "padding-left": px(size.spacing_16), + "padding-right": px(size.spacing_16), + }, + oncreate: (vnode) => (this._domInputWrapper = vnode.dom as HTMLElement), + }, + [ + a.type !== TextFieldType.Area ? this._getInputField(a) : this._getTextArea(a), + a.injectionsRight + ? m( + ".flex-end.items-center", + { + style: { + minHeight: px(minInputHeight - 2), + position: "relative", + top: px(borderWidth / 2), + }, + }, + a.injectionsRight(), + ) + : null, + ], + ), + ], + ), + ]), + ], + ), + + a.helpLabel && + m( + "small.noselect", + { + onclick: (e: MouseEvent) => { + e.stopPropagation() + }, + }, + a.helpLabel(), + ), + ] + } + + _getInputField(a: LoginTextFieldAttrs): Children { + if (a.isReadOnly) { + return m( + ".text-break.selectable", + { + style: { + marginTop: px(inputMarginTop), + lineHeight: px(font_size.line_height_input), + }, + "data-testid": `tfi:${lang.getTestId(a.label)}`, + }, + a.value, + ) + } else { + // Due to modern browser's 'smart' password managers that try to autofill everything + // that remotely resembles a password field, we prepend invisible inputs to password fields + // that shouldn't be autofilled. + // since the autofill algorithm looks at inputs that come before and after the password field we need + // three dummies. + const autofillGuard: Children = + a.autocompleteAs === Autocomplete.off + ? [ + m("input.abs", { + style: { + opacity: "0", + height: "0", + }, + tabIndex: TabIndex.Programmatic, + type: TextFieldType.Text, + }), + m("input.abs", { + style: { + opacity: "0", + height: "0", + }, + tabIndex: TabIndex.Programmatic, + type: TextFieldType.Password, + }), + m("input.abs", { + style: { + opacity: "0", + height: "0", + }, + tabIndex: TabIndex.Programmatic, + type: TextFieldType.Text, + }), + ] + : [] + return m( + ".flex-grow.rel", + autofillGuard.concat([ + m("input.input" + (a.alignRight ? ".right" : ""), { + autocomplete: a.autocompleteAs ?? "", + autocapitalize: a.autocapitalize, + type: a.type, + min: a.min, + max: a.max, + "aria-label": lang.getTranslationText(a.label), + disabled: a.disabled, + class: getOperatingClasses(a.disabled) + " text", + oncreate: (vnode) => { + this.domInput = vnode.dom as HTMLInputElement + a.onDomInputCreated?.(this.domInput) + this.domInput.value = a.value + if (a.type !== TextFieldType.Area) { + ;(vnode.dom as HTMLElement).addEventListener("animationstart", (e: AnimationEvent) => { + if (e.animationName === "onAutoFillStart") { + this._didAutofill = true + m.redraw() + } else if (e.animationName === "onAutoFillCancel") { + this._didAutofill = false + m.redraw() + } + }) + } + }, + onfocus: (e: FocusEvent) => { + this.focus(e, a) + a.onfocus?.(this._domWrapper, this.domInput) + }, + onblur: (e: FocusEvent) => this.blur(e, a), + onkeydown: (e: KeyboardEvent) => { + if (a.onReturnKeyPressed != null && e.key?.toLowerCase() === Keys.RETURN.code) { + a.onReturnKeyPressed() + return false + } + + const handled = useKeyHandler(e, a.keyHandler) + if (!isKeyPressed(e.key, Keys.F1, Keys.TAB, Keys.ESC) && !(e.ctrlKey || e.metaKey)) { + // When we are in a text field we don't want keys propagated up to act as hotkeys + e.stopPropagation() + } + return handled + }, + onupdate: () => { + // only change the value if the value has changed otherwise the cursor in Safari and in the iOS App cannot be positioned. + if (this.domInput.value !== a.value) { + this.domInput.value = a.value + } + }, + oninput: () => { + a.oninput?.(this.domInput.value, this.domInput) + }, + onremove: () => { + // We clean up any value that might still be in DOM e.g. password + if (this.domInput) this.domInput.value = "" + }, + style: { + maxWidth: a.maxWidth, + minWidth: px(20), + // fix for edge browser. buttons are cut off in small windows otherwise + lineHeight: px(font_size.line_height_input), + fontSize: a.fontSize, + position: "relative", + bottom: px(size.spacing_4), + }, + "data-testid": `tfi:${lang.getTestId(a.label)}`, + }), + ]), + ) + } + } + + _getTextArea(a: LoginTextFieldAttrs): Children { + if (a.isReadOnly) { + return m( + ".text-prewrap.text-break.selectable", + { + style: { + marginTop: px(inputMarginTop), + lineHeight: px(font_size.line_height_input), + }, + }, + a.value, + ) + } else { + return m("textarea.input-area.text-pre", { + "aria-label": lang.getTranslationText(a.label), + disabled: a.disabled, + autocapitalize: a.autocapitalize, + class: getOperatingClasses(a.disabled) + " text", + oncreate: (vnode) => { + this.domInput = vnode.dom as HTMLInputElement + this.domInput.value = a.value + this.domInput.style.height = px(Math.max(a.value.split("\n").length, 1) * font_size.line_height_input) // display all lines on creation of text area + }, + onfocus: (e: FocusEvent) => this.focus(e, a), + onblur: (e: FocusEvent) => this.blur(e, a), + onkeydown: (e: KeyboardEvent) => useKeyHandler(e, a.keyHandler), + oninput: () => { + this.domInput.style.height = "0px" + this.domInput.style.height = px(this.domInput.scrollHeight) + a.oninput?.(this.domInput.value, this.domInput) + }, + onupdate: () => { + // only change the value if the value has changed otherwise the cursor in Safari and in the iOS App cannot be positioned. + if (this.domInput.value !== a.value) { + this.domInput.value = a.value + } + }, + style: { + marginTop: px(inputMarginTop), + lineHeight: px(font_size.line_height_input), + minWidth: px(20), // fix for edge browser. buttons are cut off in small windows otherwise + fontSize: a.fontSize, + }, + }) + } + } + + focus(e: Event, a: LoginTextFieldAttrs) { + if (!this.active && !a.disabled && !a.isReadOnly) { + this.active = true + this.domInput.focus() + + this._domWrapper.classList.add("active") + } + } + + blur(e: Event, a: LoginTextFieldAttrs) { + this._domWrapper.classList.remove("active") + this.active = false + if (a.onblur instanceof Function) a.onblur(e) + } + + isEmpty(value: string): boolean { + return value === "" + } +} diff --git a/src/common/gui/base/TextField.ts b/src/common/gui/base/TextField.ts index 017698444b..38cab668dd 100644 --- a/src/common/gui/base/TextField.ts +++ b/src/common/gui/base/TextField.ts @@ -9,7 +9,6 @@ import { isKeyPressed, keyHandler, useKeyHandler } from "../../misc/KeyManager" import { Keys, TabIndex } from "../../api/common/TutanotaConstants" import { ClickHandler, getOperatingClasses } from "./GuiUtils" import { AriaPopupType } from "../AriaUtils.js" -import { AllIcons, Icon, IconSize } from "./Icon" export type TextFieldAttrs = { id?: string @@ -41,10 +40,6 @@ export type TextFieldAttrs = { fontSize?: string min?: number max?: number - leadingIcon?: { - icon: AllIcons - color: string - } /** This is called whenever the return key is pressed; overrides keyHandler */ onReturnKeyPressed?: () => unknown @@ -85,11 +80,11 @@ export const enum Autocapitalize { const inputMarginTop = font_size.small + size.spacing_4 + 3 // this is not always correct because font size can be bigger/smaller, and we ideally should take that into account -const baseLabelPosition = "-50%" +const baseLabelPosition = 21 // it should fit // compact button + 1 px border + 1 px padding to keep things centered = 32 // 24px line-height + 12px label + some space between them = 36 + ? -const minInputHeight = 56 +const minInputHeight = 46 export class TextField implements ClassComponent { active: boolean @@ -110,129 +105,93 @@ export class TextField implements ClassComponent { const labelBase = !this.active && a.value === "" && !a.isReadOnly && !this._didAutofill && !a.injectionsLeft const labelTransitionSpeed = DefaultAnimationTime / 2 const doShowBorder = a.doShowBorder !== false - const borderWidth = 3 - const borderColor = this.active ? theme.primary : "transparent" - const borderBottomRadius = this.active ? "0px" : px(size.radius_8) - - const baseStyling = { - "background-color": theme.surface_container_high, - "border-radius": `${px(size.radius_8)} ${px(size.radius_8)} ${borderBottomRadius} ${borderBottomRadius}`, - transition: `border-radius ${labelTransitionSpeed}ms ease-out`, - } - - return [ - m( - ".text-field.rel.overflow-hidden", - { - id: vnode.attrs.id, - oncreate: (vnode) => (this._domWrapper = vnode.dom as HTMLElement), - onclick: (e: MouseEvent) => (a.onclick ? a.onclick(e, this._domInputWrapper) : this.focus(e, a)), - "aria-haspopup": a.hasPopup, - "data-testid": `tf:${lang.getTestId(a.label)}`, - class: a.class != null ? a.class : "mt-16" + " " + getOperatingClasses(a.disabled), - style: maxWidth - ? { - maxWidth: px(maxWidth), - ...a.style, - ...baseStyling, - } - : { ...a.style, ...baseStyling }, - }, - [ - m( - "label.abs.text-ellipsis.noselect.z1.pr-4", - { - "aria-hidden": "true", - class: this.active ? "" : "" + " " + getOperatingClasses(a.disabled), - oncreate: (vnode) => { - this._domLabel = vnode.dom as HTMLElement - }, - style: { - fontSize: `${labelBase ? font_size.base : font_size.small}px`, - transform: `translateY(${labelBase ? baseLabelPosition : 0})`, - "transition-timing-function": "ease-out", - "transition-duration": `${labelTransitionSpeed}ms`, - "transition-property": "transform, font-size, top, color", - top: labelBase ? "50%" : px(size.spacing_8), - left: a.leadingIcon ? px(size.icon_24 + size.spacing_16) : 0, - "padding-left": px(size.spacing_16), - "padding-right": px(size.spacing_16), - color: !this.active ? "inherit" : theme.primary, - }, - }, - lang.getTranslationText(a.label), - ), - m(".flex.flex-column", [ - // another wrapper to fix IE 11 min-height bug https://github.com/philipwalton/flexbugs#3-min-height-on-a-flex-container-wont-apply-to-its-flex-items - m( - ".flex.items-end.flex-wrap", - { - // .flex-wrap - style: { - "min-height": px(minInputHeight), - "border-bottom": doShowBorder ? `${px(borderWidth)} solid ${borderColor}` : "", - transition: `border-bottom ${labelTransitionSpeed}ms ease-out`, - }, - }, - [ - a.leadingIcon && - m(Icon, { - size: IconSize.PX20, - icon: a.leadingIcon.icon, - style: { - fill: a.leadingIcon.color, - "align-self": "center", - "padding-left": px(16), - position: "relative", - top: px(borderWidth / 2), - }, - }), - a.injectionsLeft ? a.injectionsLeft() : null, // additional wrapper element for bubble input field. input field should always be in one line with right injections - m( - ".inputWrapper.flex-space-between.items-end", - { - style: { - minHeight: px(minInputHeight - 2), // minus padding - - "padding-left": px(size.spacing_16), - "padding-right": px(size.spacing_16), - }, - oncreate: (vnode) => (this._domInputWrapper = vnode.dom as HTMLElement), - }, - [ - a.type !== TextFieldType.Area ? this._getInputField(a) : this._getTextArea(a), - a.injectionsRight - ? m( - ".flex-end.items-center", - { - style: { - minHeight: px(minInputHeight - 2), - position: "relative", - top: px(borderWidth / 2), - }, - }, - a.injectionsRight(), - ) - : null, - ], - ), - ], - ), - ]), - ], - ), - - a.helpLabel && + const borderWidth = this.active ? "2px" : "1px" + const borderColor = this.active ? theme.primary : theme.outline_variant + return m( + ".text-field.rel.overflow-hidden", + { + id: vnode.attrs.id, + oncreate: (vnode) => (this._domWrapper = vnode.dom as HTMLElement), + onclick: (e: MouseEvent) => (a.onclick ? a.onclick(e, this._domInputWrapper) : this.focus(e, a)), + "aria-haspopup": a.hasPopup, + "data-testid": `tf:${lang.getTestId(a.label)}`, + class: a.class != null ? a.class : "pt-16" + " " + getOperatingClasses(a.disabled), + style: maxWidth + ? { + maxWidth: px(maxWidth), + ...a.style, + } + : { ...a.style }, + }, + [ m( - "small.noselect", + "label.abs.text-ellipsis.noselect.z1.i.pr-4", { - onclick: (e: MouseEvent) => { - e.stopPropagation() + "aria-hidden": "true", + class: this.active ? "content-accent-fg" : "" + " " + getOperatingClasses(a.disabled), + oncreate: (vnode) => { + this._domLabel = vnode.dom as HTMLElement + }, + style: { + fontSize: `${labelBase ? font_size.base : font_size.small}px`, + transform: `translateY(${labelBase ? baseLabelPosition : 0}px)`, + transition: `transform ${labelTransitionSpeed}ms ease-out, font-size ${labelTransitionSpeed}ms ease-out`, }, }, - a.helpLabel(), + lang.getTranslationText(a.label), ), - ] + m(".flex.flex-column", [ + // another wrapper to fix IE 11 min-height bug https://github.com/philipwalton/flexbugs#3-min-height-on-a-flex-container-wont-apply-to-its-flex-items + m( + ".flex.items-end.flex-wrap", + { + // .flex-wrap + style: { + "min-height": px(minInputHeight), + // 2 px border + "padding-bottom": this.active ? px(0) : px(1), + "border-bottom": doShowBorder ? `${borderWidth} solid ${borderColor}` : "", + }, + }, + [ + a.injectionsLeft ? a.injectionsLeft() : null, // additional wrapper element for bubble input field. input field should always be in one line with right injections + m( + ".inputWrapper.flex-space-between.items-end", + { + style: { + minHeight: px(minInputHeight - 2), // minus padding + }, + oncreate: (vnode) => (this._domInputWrapper = vnode.dom as HTMLElement), + }, + [ + a.type !== TextFieldType.Area ? this._getInputField(a) : this._getTextArea(a), + a.injectionsRight + ? m( + ".flex-end.items-center", + { + style: { minHeight: px(minInputHeight - 2) }, + }, + a.injectionsRight(), + ) + : null, + ], + ), + ], + ), + ]), + a.helpLabel + ? m( + "small.noselect", + { + onclick: (e: MouseEvent) => { + e.stopPropagation() + }, + }, + a.helpLabel(), + ) + : [], + ], + ) } _getInputField(a: TextFieldAttrs): Children { @@ -348,8 +307,6 @@ export class TextField implements ClassComponent { // fix for edge browser. buttons are cut off in small windows otherwise lineHeight: px(font_size.line_height_input), fontSize: a.fontSize, - position: "relative", - bottom: px(size.spacing_4), }, "data-testid": `tfi:${lang.getTestId(a.label)}`, }), diff --git a/src/common/gui/main-styles.ts b/src/common/gui/main-styles.ts index fa289d7ad4..2a35d75875 100644 --- a/src/common/gui/main-styles.ts +++ b/src/common/gui/main-styles.ts @@ -3274,5 +3274,14 @@ styles.registerStyle("main", () => { ".tutaui-button-ghost:active": { "background-color": theme.state_bg_active, }, + ".login-textfield": { + "background-color": theme.surface_container_high, + "transition-property": "border-radius, background-color", + "transition-duration": `${DefaultAnimationTime / 2}ms`, + "transition-timing-function": "ease-out", + }, + ".login-textfield:hover:not(:has(input:focus))": { + "background-color": theme.state_bg_focus, + }, } }) diff --git a/src/common/login/SignupTextField.ts b/src/common/login/SignupTextField.ts deleted file mode 100644 index f0b0da5fcf..0000000000 --- a/src/common/login/SignupTextField.ts +++ /dev/null @@ -1,45 +0,0 @@ -import m, { Component, Vnode } from "mithril" -import { TextField, TextFieldAttrs, TextFieldType } from "../gui/base/TextField" -import { font_size, px, size } from "../gui/size" -import { theme } from "../gui/theme" - -export type SignupTextFieldAttrs = TextFieldAttrs - -export class SignupTextField implements Component { - view({ attrs }: Vnode) { - const customStyle = { - backgroundColor: theme.surface_container_highest, - color: theme.on_surface_variant, - border: "none", - borderRadius: px(size.radius_8), - paddingTop: px(size.spacing_8), - paddingBottom: px(size.spacing_8), - paddingRight: px(size.spacing_8), - paddingLeft: px(40), - "font-size": px(font_size.base), - height: px(56), - - ...attrs.style, - } - return m(TextField, { - // 1. Pass through functional attributes - value: attrs.value, - oninput: attrs.oninput, - type: attrs.type || TextFieldType.Text, - label: attrs.label, - - leadingIcon: attrs.leadingIcon - ? { - icon: attrs.leadingIcon.icon, - color: theme.on_surface_variant, - } - : undefined, - - style: customStyle as any, - - onblur: attrs.onblur, - onfocus: attrs.onfocus, - onclick: attrs.onclick, - } satisfies TextFieldAttrs) - } -} diff --git a/src/common/login/SignupView.ts b/src/common/login/SignupView.ts index e932522420..989ee8d93b 100644 --- a/src/common/login/SignupView.ts +++ b/src/common/login/SignupView.ts @@ -56,8 +56,8 @@ import { UpgradeCongratulationsPageNew } from "../subscription/UpgradeCongratula import { RadioSelector, type RadioSelectorAttrs } from "../gui/base/RadioSelector" import { type RadioSelectorOption } from "../gui/base/RadioSelectorItem" import { theme } from "../gui/theme" -import { TextField, TextFieldAttrs } from "../gui/base/TextField" import { Icons } from "../gui/base/icons/Icons" +import { LoginTextField } from "../gui/base/LoginTextField" assertMainOrNode() @@ -279,7 +279,7 @@ export class SignupView extends BaseTopLevelView implements TopLevelView (this.wizardViewModel.addressInputStore = newValue), label: lang.getTranslation("address_label"), value: this.wizardViewModel.addressInputStore ?? "", @@ -287,7 +287,7 @@ export class SignupView extends BaseTopLevelView implements TopLevelView