maintain separate implementation for LoginTextField

Co-authored-by: yoy <yoy@tutao.de>
Co-authored-by: hak <hak@tutao.de>
This commit is contained in:
toj 2025-12-02 15:47:31 +01:00
parent afddfe11d1
commit 780a4dd3c9
5 changed files with 487 additions and 175 deletions

View file

@ -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<Children> | null
alignRight?: boolean
injectionsLeft?: lazy<Children>
// only used by the BubbleTextField (-> uses old TextField) to display bubbles and out of office notification
injectionsRight?: lazy<Children>
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>) => any
maxWidth?: number
class?: string
style?: Record<string, any> //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<LoginTextFieldAttrs> {
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<LoginTextFieldAttrs>): 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 === ""
}
}

View file

@ -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<TextFieldAttrs> {
active: boolean
@ -110,129 +105,93 @@ export class TextField implements ClassComponent<TextFieldAttrs> {
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<TextFieldAttrs> {
// 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)}`,
}),

View file

@ -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,
},
}
})

View file

@ -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<SignupTextFieldAttrs> {
view({ attrs }: Vnode<SignupTextFieldAttrs>) {
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)
}
}

View file

@ -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<SignupV
},
[
m(TextField, {
m(LoginTextField, {
oninput: (newValue) => (this.wizardViewModel.addressInputStore = newValue),
label: lang.getTranslation("address_label"),
value: this.wizardViewModel.addressInputStore ?? "",
@ -287,7 +287,7 @@ export class SignupView extends BaseTopLevelView implements TopLevelView<SignupV
icon: Icons.Eye,
color: theme.on_surface_variant,
},
} satisfies TextFieldAttrs),
}),
m(
".flex-center.full-width.pt-32",
m(LoginButton, {