From 49d28f2772fd11b80ff6d5967a400ab9cd7c5376 Mon Sep 17 00:00:00 2001 From: mup Date: Fri, 15 Nov 2024 15:07:43 +0100 Subject: [PATCH] Implements Guests Page This commit implements the Guest Pages and the GuestPicker, allowing to invite guests when creating a new event. Also fixes the Reminder selector not correctly handling the custom option due event loop race conditions. Co-authored-by: rih --- .../calendar/gui/CalendarGuiUtils.ts | 13 +- .../calendar/gui/RemindersEditor.ts | 18 +- .../CalendarEventWhoModel.ts | 2 + .../eventeditor-view/AttendeeListEditor.ts | 510 ++++++++++-------- .../eventeditor-view/CalendarEventEditView.ts | 40 +- .../calendar/gui/pickers/GuestPicker.ts | 264 +++++++++ .../calendar/gui/pickers/TimePicker.ts | 7 +- src/common/gui/MailRecipientsTextField.ts | 4 +- src/common/gui/PasswordInput.ts | 52 ++ src/common/gui/base/Dropdown.ts | 11 +- src/common/gui/base/Select.ts | 96 +++- src/common/gui/base/SingleLineTextField.ts | 50 +- src/common/gui/main-styles.ts | 3 + 13 files changed, 800 insertions(+), 270 deletions(-) create mode 100644 src/calendar-app/calendar/gui/pickers/GuestPicker.ts create mode 100644 src/common/gui/PasswordInput.ts diff --git a/src/calendar-app/calendar/gui/CalendarGuiUtils.ts b/src/calendar-app/calendar/gui/CalendarGuiUtils.ts index 45a8ad8e8cf..a35499c322c 100644 --- a/src/calendar-app/calendar/gui/CalendarGuiUtils.ts +++ b/src/calendar-app/calendar/gui/CalendarGuiUtils.ts @@ -80,6 +80,7 @@ import { CalendarEventPreviewViewModel } from "./eventpopup/CalendarEventPreview import { createAsyncDropdown } from "../../../common/gui/base/Dropdown.js" import { UserController } from "../../../common/api/main/UserController.js" import { ClientOnlyCalendarsInfo } from "../../../common/misc/DeviceConfig.js" +import { SelectOption } from "../../../common/gui/base/Select.js" export function renderCalendarSwitchLeftButton(label: TranslationKey, click: () => unknown): Child { return m(IconButton, { @@ -422,23 +423,33 @@ export const createAlarmIntervalItems = (locale: string): SelectorItemList => [ + +export interface AttendingItem extends SelectOption { + name: string + selectable?: boolean +} + +export const createAttendingItems = (): AttendingItem[] => [ { name: lang.get("yes_label"), value: CalendarAttendeeStatus.ACCEPTED, + ariaValue: lang.get("yes_label"), }, { name: lang.get("maybe_label"), value: CalendarAttendeeStatus.TENTATIVE, + ariaValue: lang.get("maybe_label"), }, { name: lang.get("no_label"), value: CalendarAttendeeStatus.DECLINED, + ariaValue: lang.get("no_label"), }, { name: lang.get("pending_label"), value: CalendarAttendeeStatus.NEEDS_ACTION, selectable: false, + ariaValue: lang.get("pending_label"), }, ] diff --git a/src/calendar-app/calendar/gui/RemindersEditor.ts b/src/calendar-app/calendar/gui/RemindersEditor.ts index 3672605e638..57f25283c23 100644 --- a/src/calendar-app/calendar/gui/RemindersEditor.ts +++ b/src/calendar-app/calendar/gui/RemindersEditor.ts @@ -13,6 +13,7 @@ import { Select, SelectAttributes, SelectOption } from "../../../common/gui/base import { Icon, IconSize } from "../../../common/gui/base/Icon.js" import { BaseButton } from "../../../common/gui/base/buttons/BaseButton.js" import { ButtonColor, getColors } from "../../../common/gui/base/Button.js" +import stream from "mithril/stream" export type RemindersEditorAttrs = { addAlarm: (alarm: AlarmInterval) => unknown @@ -150,17 +151,20 @@ export class RemindersEditor implements Component { m(Select, { ariaLabel: lang.get("calendarReminderIntervalValue_label"), selected: defaultSelected, - options: alarmOptions, + options: stream(alarmOptions), renderOption: (option) => this.renderReminderOptions(option, false, false), renderDisplay: (option) => this.renderReminderOptions(option, alarms.length > 0, true), - onChange: (newValue) => { + onchange: (newValue) => { if (newValue.value.value === -1) { - return this.showCustomReminderIntervalDialog((value, unit) => { - addNewAlarm({ - value, - unit, + // timeout needed to prevent the custom interval dialog to be closed by the key event triggered inside the select component + return setTimeout(() => { + this.showCustomReminderIntervalDialog((value, unit) => { + addNewAlarm({ + value, + unit, + }) }) - }) + }, 0) } addAlarm(newValue.value) }, diff --git a/src/calendar-app/calendar/gui/eventeditor-model/CalendarEventWhoModel.ts b/src/calendar-app/calendar/gui/eventeditor-model/CalendarEventWhoModel.ts index ee54c148bc2..fa909128076 100644 --- a/src/calendar-app/calendar/gui/eventeditor-model/CalendarEventWhoModel.ts +++ b/src/calendar-app/calendar/gui/eventeditor-model/CalendarEventWhoModel.ts @@ -347,6 +347,8 @@ export class CalendarEventWhoModel { // if there are other attendees and we have an organizer that's us, we must use that organizer // because changing the organizer address after the attendees were invited is suboptimal. return [this._organizer.address] + } else if (this.eventType === EventType.OWN) { + return this.ownMailAddresses } else { // something is wrong. throw new ProgrammingError("could not figure out which addresses are a valid organizer for this event.") diff --git a/src/calendar-app/calendar/gui/eventeditor-view/AttendeeListEditor.ts b/src/calendar-app/calendar/gui/eventeditor-view/AttendeeListEditor.ts index ba20306df0f..14e6f9246d2 100644 --- a/src/calendar-app/calendar/gui/eventeditor-view/AttendeeListEditor.ts +++ b/src/calendar-app/calendar/gui/eventeditor-view/AttendeeListEditor.ts @@ -1,32 +1,32 @@ import m, { Children, Component, Vnode } from "mithril" -import { MailRecipientsTextField } from "../../../../common/gui/MailRecipientsTextField.js" import { RecipientType } from "../../../../common/api/common/recipients/Recipient.js" import { ToggleButton } from "../../../../common/gui/base/buttons/ToggleButton.js" import { Icons } from "../../../../common/gui/base/icons/Icons.js" import { ButtonSize } from "../../../../common/gui/base/ButtonSize.js" -import { Checkbox } from "../../../../common/gui/base/Checkbox.js" import { lang } from "../../../../common/misc/LanguageViewModel.js" import { AccountType, CalendarAttendeeStatus } from "../../../../common/api/common/TutanotaConstants.js" -import { Autocomplete } from "../../../../common/gui/base/TextField.js" import { RecipientsSearchModel } from "../../../../common/misc/RecipientsSearchModel.js" -import { noOp } from "@tutao/tutanota-utils" import { Guest } from "../../view/CalendarInvites.js" -import { Icon } from "../../../../common/gui/base/Icon.js" +import { Icon, IconSize } from "../../../../common/gui/base/Icon.js" import { theme } from "../../../../common/gui/theme.js" import { IconButton } from "../../../../common/gui/base/IconButton.js" -import { BootIcons } from "../../../../common/gui/base/icons/BootIcons.js" import { px, size } from "../../../../common/gui/size.js" -import { createDropdown } from "../../../../common/gui/base/Dropdown.js" import { CalendarEventWhoModel } from "../eventeditor-model/CalendarEventWhoModel.js" import { LoginController } from "../../../../common/api/main/LoginController.js" import { CalendarEventModel, CalendarOperation } from "../eventeditor-model/CalendarEventModel.js" -import { DropDownSelector } from "../../../../common/gui/base/DropDownSelector.js" import { showPlanUpgradeRequiredDialog } from "../../../../common/misc/SubscriptionDialogs.js" import { hasPlanWithInvites } from "../eventeditor-model/CalendarNotificationModel.js" import { Dialog } from "../../../../common/gui/base/Dialog.js" -import { createAttendingItems, iconForAttendeeStatus } from "../CalendarGuiUtils.js" -import { PasswordField } from "../../../../common/misc/passwords/PasswordField.js" +import { AttendingItem, createAttendingItems, iconForAttendeeStatus } from "../CalendarGuiUtils.js" +import { Card } from "../../../../common/gui/base/Card.js" +import { Select, SelectAttributes } from "../../../../common/gui/base/Select.js" +import stream from "mithril/stream" +import { OrganizerSelectItem } from "./CalendarEventEditView.js" +import { GuestPicker } from "../pickers/GuestPicker.js" +import { IconMessageBox } from "../../../../common/gui/base/ColumnEmptyMessageBox.js" +import { PasswordInput } from "../../../../common/gui/PasswordInput.js" +import { Switch } from "../../../../common/gui/base/Switch.js" export type AttendeeListEditorAttrs = { /** the event that is currently being edited */ @@ -42,243 +42,321 @@ export type AttendeeListEditorAttrs = { * including the own attendance, the own organizer address and external passwords. */ export class AttendeeListEditor implements Component { - private text: string = "" private hasPlanWithInvites: boolean = false view({ attrs }: Vnode): Children { - return [m(".flex-grow", this.renderInvitationField(attrs)), m(".flex-grow", this.renderGuestList(attrs))] - } - - private renderInvitationField(attrs: AttendeeListEditorAttrs): Children { - const { model, recipientsSearch, logins } = attrs - if (!model.editModels.whoModel.canModifyGuests) return null - return m(".flex.flex-column.flex-grow", [ - m(MailRecipientsTextField, { - label: "addGuest_label", - text: this.text, - onTextChanged: (v) => (this.text = v), - // we don't show bubbles, we just want the search dropdown - recipients: [], - disabled: false, - onRecipientAdded: async (address, name, contact) => { - if (!(await hasPlanWithInvites(logins)) && !this.hasPlanWithInvites) { - if (logins.getUserController().user.accountType === AccountType.EXTERNAL) return - if (logins.getUserController().isGlobalAdmin()) { - const { getAvailablePlansWithEventInvites } = await import("../../../../common/subscription/SubscriptionUtils.js") - const plansWithEventInvites = await getAvailablePlansWithEventInvites() - if (plansWithEventInvites.length === 0) return - //entity event updates are too slow to call updateBusinessFeature() - this.hasPlanWithInvites = await showPlanUpgradeRequiredDialog(plansWithEventInvites) - // the user could have, but did not upgrade. - if (!this.hasPlanWithInvites) return - } else { - Dialog.message(() => lang.get("contactAdmin_msg")) - } - } else { - model.editModels.whoModel.addAttendee(address, contact) - } - }, - // do nothing because we don't have any bubbles here - onRecipientRemoved: noOp, - injectionsRight: this.renderIsConfidentialToggle(attrs), - search: recipientsSearch, - }), - this.renderSendUpdateCheckbox(attrs), - ]) - } - - private renderIsConfidentialToggle(attrs: AttendeeListEditorAttrs): Children { const { whoModel } = attrs.model.editModels - const guests = whoModel.guests - if (!guests.some((a) => a.type === RecipientType.EXTERNAL)) return null - return m(ToggleButton, { - title: "confidential_action", - onToggled: (_, e) => { - whoModel.isConfidential = !whoModel.isConfidential - e.stopPropagation() - }, - icon: whoModel.isConfidential ? Icons.Lock : Icons.Unlock, - toggled: whoModel.isConfidential, - size: ButtonSize.Compact, - }) - } - - private renderSendUpdateCheckbox({ model }: AttendeeListEditorAttrs): Children { - const { whoModel } = model.editModels - return !whoModel.initiallyHadOtherAttendees - ? null - : m( - ".mt-negative-s", - m(Checkbox, { - label: () => lang.get("sendUpdates_label"), - onChecked: (v) => (whoModel.shouldSendUpdates = v), - checked: whoModel.shouldSendUpdates, - }), - ) + const organizer = whoModel.organizer + return [ + m(".flex-grow.flex.flex-column.gap-vpad-sm-15", [ + this.renderOrganizer(attrs.model, organizer), + m(".flex.flex-column.gap-vpad-xs", [ + whoModel.canModifyGuests ? this.renderGuestsInput(whoModel, attrs.logins, attrs.recipientsSearch) : null, + this.renderSendUpdateCheckbox(attrs.model.editModels.whoModel), + this.renderGuestList(attrs, organizer), + ]), + ]), + ] } - /** - * render the list of guests, always putting the organizer on top, then the rest, - * followed by the external passwords. - * - * in cases where we can see the event editor AND we have to render a guest list, we're guaranteed to be the organizer. - * @private - */ - private renderGuestList(attrs: AttendeeListEditorAttrs): Children { + private renderGuestList(attrs: AttendeeListEditorAttrs, organizer: Guest | null): Children { const { whoModel } = attrs.model.editModels - const organizer = whoModel.organizer - const guests: Array = whoModel.guests.slice() - const attendeeRenderers: Array<() => Children> = [] - - if (organizer != null) { - attendeeRenderers.push(() => renderOrganizer(organizer, attrs)) - } + const guestItems: (() => Children)[] = [] for (const guest of whoModel.guests) { - attendeeRenderers.push(() => renderGuest(guest, attrs)) + let password: string + let strength: number + + if (guest.type === RecipientType.EXTERNAL) { + const presharedPassword = whoModel.getPresharedPassword(guest.address) + password = presharedPassword.password + strength = presharedPassword.strength + } + + guestItems.push(() => this.renderGuest(guest, attrs, password, strength)) } // ownGuest is never in the guest list, but it may be identical to organizer. const ownGuest = whoModel.ownGuest if (ownGuest != null && ownGuest.address !== organizer?.address) { - attendeeRenderers.push(() => renderGuest(ownGuest, attrs)) + guestItems.push(() => this.renderGuest(ownGuest, attrs)) } - const externalGuestPasswords = whoModel.isConfidential - ? guests - .filter((a) => a.type === RecipientType.EXTERNAL) - .map((guest) => { - const { address } = guest - const { password, strength } = whoModel.getPresharedPassword(address) - return m(PasswordField, { - value: password, - passwordStrength: strength, - autocompleteAs: Autocomplete.off, - label: () => - lang.get("passwordFor_label", { - "{1}": guest.address, - }), - key: address, - oninput: (newValue) => whoModel.setPresharedPassword(address, newValue), - }) - }) - : [] - - return m("", [...attendeeRenderers.map((r) => r()), externalGuestPasswords]) + const verticalPadding = guestItems.length > 0 ? size.vpad_small : 0 + return m( + Card, + { + classes: ["min-h-s flex flex-column gap-vpad-sm"], + style: { + padding: `${px(verticalPadding)} ${px(guestItems.length === 0 ? size.vpad_small : 0)} ${px(size.vpad_small)} ${px(verticalPadding)}`, + }, + }, + [...guestItems.map((r) => r()), this.renderNoGuests(guestItems.length === 0)], + ) } -} - -/** - * - * @param editModel the event to set the organizer on when a button in the dropdown is clicked - * @param e - */ -function showOrganizerDropdown(editModel: CalendarEventWhoModel, e: MouseEvent) { - const lazyButtons = () => - editModel.possibleOrganizers.map((organizer) => { - return { - label: () => organizer.address, - click: () => editModel.addAttendee(organizer.address, null), - } - }) - createDropdown({ lazyButtons, width: 300 })(e, e.target as HTMLElement) -} + private renderNoGuests(isEmpty: boolean) { + return isEmpty + ? m(".flex.items-center.justify-center.min-h-s", [ + m(IconMessageBox, { + message: "noEntries_msg", + icon: Icons.People, + color: theme.list_message_bg, + }), + ]) + : null + } -function renderOrganizer(organizer: Guest, { model }: Pick): Children { - const { whoModel } = model.editModels - const { address, name, status } = organizer - const isMe = organizer.address === whoModel.ownGuest?.address - const editableOrganizer = whoModel.possibleOrganizers.length > 1 && isMe - const roleLabel = isMe ? `${lang.get("organizer_label")} | ${lang.get("you_label")}` : lang.get("organizer_label") - const statusLine = m(".small.flex.center-vertically", [renderStatusIcon(status), roleLabel]) - const fullName = m("div.text-ellipsis", { style: { lineHeight: px(24) } }, name.length > 0 ? `${name} ${address}` : address) - const nameAndAddress = editableOrganizer - ? m(".flex.flex-grow.items-center.click", { onclick: (e: MouseEvent) => showOrganizerDropdown(whoModel, e) }, [ - fullName, - m(Icon, { - icon: BootIcons.Expand, - style: { - fill: theme.content_fg, - }, - }), - ]) - : m(".flex.flex-grow.items-center", fullName) + private renderGuestsInput(whoModel: CalendarEventWhoModel, logins: LoginController, recipientsSearch: RecipientsSearchModel): Children { + const guests = whoModel.guests + const hasExternalGuests = guests.some((a) => a.type === RecipientType.EXTERNAL) - const rightContent = - // this prevents us from setting our own attendance on a single instance that we're editing. - model.operation !== CalendarOperation.EditThis - ? isMe + return m(".flex.items-center.flex-grow.gap-vpad-sm", [ + m(Card, { style: { padding: "0" }, classes: ["flex-grow"] }, [ + m(".flex.flex-grow.rel.button-height", [ + m(GuestPicker, { + ariaLabel: "addGuest_label", + disabled: false, + onRecipientAdded: async (address, name, contact) => { + if (!(await hasPlanWithInvites(logins)) && !this.hasPlanWithInvites) { + if (logins.getUserController().user.accountType === AccountType.EXTERNAL) return + if (logins.getUserController().isGlobalAdmin()) { + const { getAvailablePlansWithEventInvites } = await import("../../../../common/subscription/SubscriptionUtils.js") + const plansWithEventInvites = await getAvailablePlansWithEventInvites() + if (plansWithEventInvites.length === 0) return + //entity event updates are too slow to call updateBusinessFeature() + this.hasPlanWithInvites = await showPlanUpgradeRequiredDialog(plansWithEventInvites) + // the user could have, but did not upgrade. + if (!this.hasPlanWithInvites) return + } else { + Dialog.message(() => lang.get("contactAdmin_msg")) + } + } else { + whoModel.addAttendee(address, contact) + } + }, + search: recipientsSearch, + }), + ]), + ]), + hasExternalGuests ? m( - "", - { style: { minWidth: "120px" } }, - m(DropDownSelector, { - label: "attending_label", - items: createAttendingItems(), - selectedValue: status, - class: "", - selectionChangedHandler: (value: CalendarAttendeeStatus) => { - if (value == null) return - whoModel.setOwnAttendance(value) + Card, + { style: { padding: "0" } }, + m(ToggleButton, { + title: "confidential_action", + onToggled: (_, e) => { + whoModel.isConfidential = !whoModel.isConfidential + e.stopPropagation() }, + icon: whoModel.isConfidential ? Icons.Lock : Icons.Unlock, + toggled: whoModel.isConfidential, + size: ButtonSize.Normal, }), ) - : m(IconButton, { - title: "sendMail_alt", - click: async () => - (await import("../../../../mail-app/contacts/view/ContactView.js")).writeMail( - organizer, - lang.get("repliedToEventInvite_msg", { - "{event}": model.editModels.summary.content, - }), + : null, + ]) + } + + private renderAttendeeStatus(organizer: Guest, model: CalendarEventWhoModel): Children { + const { status } = organizer + + const attendingOptions = createAttendingItems().filter((option) => option.selectable !== false) + const attendingStatus = attendingOptions.find((option) => option.value === status) + + return m(".flex.flex-column", [ + m(".small", { style: { lineHeight: px(size.vpad_small) } }, lang.get("attending_label")), + m(Select, { + onchange: (option) => { + if (option.selectable === false) return + model.setOwnAttendance(option.value) + }, + selected: attendingStatus, + disabled: false, + ariaLabel: lang.get("organizer_label"), + renderOption: (option) => + m( + "button.items-center.flex-grow.state-bg.button-content.dropdown-button.pt-s.pb-s", + { + class: option.selectable === false ? `no-hover` : "", + style: { color: option.value === status ? theme.content_button_selected : undefined }, + }, + option.name, + ), + renderDisplay: (option) => m("", option.name), + options: stream(attendingOptions), + expanded: true, + } satisfies SelectAttributes), + ]) + } + + private renderOrganizer(model: CalendarEventModel, organizer: Guest | null): Children { + const { whoModel } = model.editModels + + if (!(whoModel.possibleOrganizers.length > 0 || organizer)) { + console.log("Trying to access guest without organizer") + return null + } + + const { address, name, status } = organizer ?? {} + const hasGuest = whoModel.guests.length > 0 + const isMe = organizer?.address === whoModel.ownGuest?.address + const editableOrganizer = whoModel.possibleOrganizers.length > 1 && isMe + + const options = whoModel.possibleOrganizers.map((organizer) => { + return { + name: organizer.name, + address: organizer.address, + ariaValue: organizer.address, + value: organizer.address, + } + }) + + const disabled = !editableOrganizer || !hasGuest + const selected = options.find((option) => option.address === address) ?? options[0] + + return m(Card, [ + m(".flex.flex-column.gap-vpad-sm", [ + m(".flex", [ + m(Select, { + classes: ["flex-grow"], + onchange: (option) => { + const organizer = whoModel.possibleOrganizers.find((organizer) => organizer.address === option.address) + if (organizer) { + whoModel.addAttendee(organizer.address, null) + } + }, + selected, + disabled, + ariaLabel: lang.get("organizer_label"), + renderOption: (option) => + m( + "button.items-center.flex-grow.state-bg.button-content.dropdown-button.pt-s.pb-s", + { style: { color: selected.address === option.address ? theme.content_button_selected : undefined } }, + option.address, ), - icon: Icons.PencilSquare, - }) - : null + renderDisplay: (option) => m("", option.name ? `${option.name} <${option.address}>` : option.address), + options: stream( + whoModel.possibleOrganizers.map((organizer) => { + return { + name: organizer.name, + address: organizer.address, + ariaValue: organizer.address, + value: organizer.address, + } + }), + ), + noIcon: disabled, + expanded: true, + } satisfies SelectAttributes), + model.operation !== CalendarOperation.EditThis && organizer && !isMe + ? m(IconButton, { + title: "sendMail_alt", + click: async () => + (await import("../../../../mail-app/contacts/view/ContactView.js")).writeMail( + organizer, + lang.get("repliedToEventInvite_msg", { + "{event}": model.editModels.summary.content, + }), + ), + size: ButtonSize.Compact, + icon: Icons.PencilSquare, + }) + : null, + ]), + isMe && organizer && model.operation !== CalendarOperation.EditThis ? this.renderAttendeeStatus(organizer, whoModel) : null, + ]), + ]) + } - return renderAttendee(nameAndAddress, statusLine, rightContent) -} + private renderSendUpdateCheckbox(whoModel: CalendarEventWhoModel): Children { + return !whoModel.initiallyHadOtherAttendees || !whoModel.canModifyGuests + ? null + : m( + Card, + m( + Switch, + { + checked: whoModel.shouldSendUpdates, + onclick: (value) => (whoModel.shouldSendUpdates = value), + ariaLabel: lang.get("sendUpdates_label"), + disabled: false, + variant: "expanded", + }, + lang.get("sendUpdates_label"), + ), + ) + } + + private renderGuest(guest: Guest, { model }: Pick, password?: string, strength?: number): Children { + const { whoModel } = model.editModels + const { address, name, status } = guest + const isMe = guest.address === whoModel.ownGuest?.address + const roleLabel = isMe ? `${lang.get("guest_label")} | ${lang.get("you_label")}` : lang.get("guest_label") + const renderPasswordField = whoModel.isConfidential && password != null && guest.type === RecipientType.EXTERNAL + + let rightContent: Children = null -function renderGuest(guest: Guest, { model }: Pick): Children { - const { whoModel } = model.editModels - const { address, name, status } = guest - const isMe = guest.address === whoModel.ownGuest?.address - const roleLabel = isMe ? `${lang.get("guest_label")} | ${lang.get("you_label")}` : lang.get("guest_label") - const statusLine = m(".small.flex.center-vertically", [renderStatusIcon(status), roleLabel]) - const fullName = m("div.text-ellipsis", { style: { lineHeight: px(24) } }, name.length > 0 ? `${name} ${address}` : address) - const nameAndAddress = m(".flex.flex-grow.items-center", fullName) - const rightContent = whoModel.canModifyGuests - ? m(IconButton, { + if (isMe) { + rightContent = m("", { style: { paddingRight: px(size.vpad_small) } }, this.renderAttendeeStatus(guest, model.editModels.whoModel)) + } else if (whoModel.canModifyGuests) { + rightContent = m(IconButton, { title: "remove_action", icon: Icons.Cancel, click: () => whoModel.removeAttendee(guest.address), - }) - : null - return renderAttendee(nameAndAddress, statusLine, rightContent) -} + }) + } + + return m(".flex.flex-column.items-center", [ + m(".flex.items-center.flex-grow.full-width", [ + this.renderStatusIcon(status), + m(".flex.flex-column.flex-grow", [ + m(".small", { style: { lineHeight: px(size.vpad_small) } }, roleLabel), + m(".text-ellipsis", name.length > 0 ? `${name} ${address}` : address), + ]), + rightContent, + ]), + renderPasswordField ? this.renderPasswordField(address, password, strength ?? 0, whoModel) : null, + ]) + } + + private renderPasswordField(address: string, password: string, strength: number, whoModel: CalendarEventWhoModel): Children { + const label = lang.get("passwordFor_label", { + "{1}": address, + }) + return m(".flex.flex-grow.full-width.justify-between.items-end", [ + m( + ".flex.flex-column.full-width", + { + style: { + paddingLeft: px(size.hpad_medium + size.vpad_small), + paddingRight: px((size.button_height - size.button_height_compact) / 2), + }, + }, + [ + m(".small", { style: { lineHeight: px(size.vpad) } }, label), + m(PasswordInput, { + ariaLabel: label, + password, + strength, + oninput: (newPassword) => { + whoModel.setPresharedPassword(address, newPassword) + }, + }), + ], + ), + ]) + } -function renderAttendee(nameAndAddress: Children, statusLine: Children, rightContent: Children): Children { - const spacer = m(".flex-grow") - return m( - ".flex", - { + private renderStatusIcon(status: CalendarAttendeeStatus): Children { + const icon = iconForAttendeeStatus[status] + return m(Icon, { + icon, + size: IconSize.Large, + class: "mr-s", style: { - height: px(size.button_height), - borderBottom: "1px transparent", - marginTop: px(size.vpad), + fill: theme.content_fg, }, - }, - [m(".flex.col.flex-grow.overflow-hidden.flex-no-grow-shrink-auto", [nameAndAddress, statusLine]), spacer, rightContent], - ) -} - -function renderStatusIcon(status: CalendarAttendeeStatus): Children { - const icon = iconForAttendeeStatus[status] - return m(Icon, { - icon, - class: "mr-s", - style: { - fill: theme.content_fg, - }, - }) + }) + } } diff --git a/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts b/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts index e01ca6d83aa..4d1793ba97f 100644 --- a/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts +++ b/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts @@ -10,8 +10,6 @@ import { RecipientsSearchModel } from "../../../../common/misc/RecipientsSearchM import { CalendarInfo } from "../../model/CalendarModel.js" import { AlarmInterval } from "../../../../common/calendar/date/CalendarUtils.js" import { Icons } from "../../../../common/gui/base/icons/Icons.js" -import { IconButton } from "../../../../common/gui/base/IconButton.js" -import { ButtonSize } from "../../../../common/gui/base/ButtonSize.js" import { HtmlEditor } from "../../../../common/gui/editor/HtmlEditor.js" import { BannerType, InfoBanner, InfoBannerAttrs } from "../../../../common/gui/base/InfoBanner.js" import { CalendarEventModel, CalendarOperation, ReadonlyReason } from "../eventeditor-model/CalendarEventModel.js" @@ -26,6 +24,7 @@ import { theme } from "../../../../common/gui/theme.js" import { TextFieldType } from "../../../../common/gui/base/TextField.js" import { deepEqual } from "@tutao/tutanota-utils" import { ButtonColor, getColors } from "../../../../common/gui/base/Button.js" +import stream from "mithril/stream" export type CalendarEventEditViewAttrs = { model: CalendarEventModel @@ -42,6 +41,11 @@ export interface CalendarSelectItem extends SelectOption { name: string } +export interface OrganizerSelectItem extends SelectOption { + name: string + address: string +} + /** * combines several semi-related editor components into a full editor for editing calendar events * to be displayed in a dialog. @@ -56,7 +60,6 @@ export class CalendarEventEditView implements Component - private addressURI: string = "" constructor(vnode: Vnode) { this.timeFormat = vnode.attrs.timeFormat @@ -98,6 +101,14 @@ export class CalendarEventEditView implements Component, { - onChange: (val) => { + onchange: (val) => { model.editModels.alarmModel.removeAll() model.editModels.alarmModel.addAll(this.defaultAlarms.get(val.value.group._id) ?? []) model.editModels.whoModel.selectedCalendar = val.value }, - options, + options: stream(options), expanded: true, selected, renderOption: (option) => this.renderCalendarOptions(option, deepEqual(option.value, selected.value), false), @@ -294,7 +305,7 @@ export class CalendarEventEditView implements Component { model.editModels.location.content = newValue - this.addressURI = encodeURIComponent(model.editModels.location.content) }, ariaLabel: lang.get("location_label"), placeholder: lang.get("location_label"), disabled: !model.isFullyWritable(), type: TextFieldType.Text, + leadingIcon: { + icon: Icons.Pin, + color: getColors(ButtonColor.Content).button, + }, }), - this.addressURI - ? m(IconButton, { - title: "showAddress_alt", - icon: Icons.Pin, - size: ButtonSize.Compact, - click: () => { - window.open(`https://www.openstreetmap.org/search?query=${this.addressURI}`, "_blank") - }, - }) - : null, ), ) } diff --git a/src/calendar-app/calendar/gui/pickers/GuestPicker.ts b/src/calendar-app/calendar/gui/pickers/GuestPicker.ts new file mode 100644 index 00000000000..485b7879f7e --- /dev/null +++ b/src/calendar-app/calendar/gui/pickers/GuestPicker.ts @@ -0,0 +1,264 @@ +import m, { ClassComponent, Vnode, VnodeDOM } from "mithril" +import { Select, SelectAttributes, SelectOption, SelectState } from "../../../../common/gui/base/Select.js" +import { Keys, TabIndex } from "../../../../common/api/common/TutanotaConstants.js" +import { SingleLineTextField } from "../../../../common/gui/base/SingleLineTextField.js" +import { getFirstOrThrow } from "@tutao/tutanota-utils" +import { Dialog } from "../../../../common/gui/base/Dialog.js" +import { lang, TranslationKey } from "../../../../common/misc/LanguageViewModel.js" +import { parseMailAddress, parsePastedInput, parseTypedInput } from "../../../../common/gui/MailRecipientsTextField.js" +import { Contact } from "../../../../common/api/entities/tutanota/TypeRefs.js" +import { RecipientSearchResultItem, RecipientsSearchModel } from "../../../../common/misc/RecipientsSearchModel.js" +import stream from "mithril/stream" +import { keyboardEventToKeyPress } from "../../../../common/misc/KeyManager.js" +import { theme } from "../../../../common/gui/theme.js" +import { Icons } from "../../../../common/gui/base/icons/Icons.js" +import { Icon } from "../../../../common/gui/base/Icon.js" +import { px, size } from "../../../../common/gui/size.js" + +export interface GuestPickerAttrs { + ariaLabel: TranslationKey + onRecipientAdded: (address: string, name: string | null, contact: Contact | null) => void + disabled: boolean + search: RecipientsSearchModel +} + +interface GuestItem extends SelectOption { + name: string + address?: string + type: string +} + +export class GuestPicker implements ClassComponent { + private isExpanded: boolean = false + private isFocused: boolean = false + private value: string = "" + private selected?: GuestItem + private options: stream> = stream([]) + private selectDOM: VnodeDOM> | null = null + + view({ attrs }: Vnode) { + return m(Select, { + classes: ["flex-grow"], + dropdownPosition: "bottom", + onchange: ({ value: guest }) => { + this.handleSelection(attrs, guest) + }, + onclose: () => { + this.isExpanded = false + }, + oncreate: (node: VnodeDOM>) => { + this.selectDOM = node + }, + selected: this.selected, + ariaLabel: attrs.ariaLabel, + disabled: attrs.disabled, + options: this.options, + noIcon: true, + expanded: true, + tabIndex: Number(TabIndex.Programmatic), + placeholder: this.renderSearchInput(attrs), + renderDisplay: () => this.renderSearchInput(attrs), + renderOption: (option) => this.renderSuggestionItem(option === this.selected, option), + keepFocus: true, + } satisfies SelectAttributes) + } + + private renderSuggestionItem(selected: boolean, option: GuestItem) { + const firstRow = + option.value.type === "recipient" + ? option.value.value.name + : m(Icon, { + icon: Icons.People, + style: { + fill: theme.content_fg, + "aria-describedby": lang.get("contactListName_label"), + }, + }) + const secondRow = option.value.type === "recipient" ? option.value.value.address : option.value.value.name + return m( + ".pt-s.pb-s.click.content-hover", + { + class: selected ? "content-accent-fg row-selected icon-accent" : "", + style: { + "padding-left": selected ? px(size.hpad_large - 3) : px(size.hpad_large), + "border-left": selected ? "3px solid" : null, + }, + }, + [m(".small.full-width.text-ellipsis", firstRow), m(".name.full-width.text-ellipsis", secondRow)], + ) + } + + private async handleSelection(attrs: GuestPickerAttrs, guest: RecipientSearchResultItem) { + if (guest.value != null) { + if (guest.type === "recipient") { + const { address, name, contact } = guest.value + attrs.onRecipientAdded(address, name, contact) + attrs.search.clear() + this.value = "" + } else { + attrs.search.clear() + this.value = "" + const recipients = await attrs.search.resolveContactList(guest.value) + for (const { address, name, contact } of recipients) { + attrs.onRecipientAdded(address, name, contact) + } + m.redraw() + } + } + } + + private renderSearchInput(attrs: GuestPickerAttrs) { + return m(SingleLineTextField, { + classes: ["height-100p"], + value: this.value, + placeholder: lang.get("addGuest_label"), + onclick: (e: MouseEvent) => { + e.stopImmediatePropagation() + if (!this.isExpanded && this.value.length > 0 && this.selectDOM) { + ;(this.selectDOM.dom as HTMLElement).click() + this.isExpanded = true + } + }, + oninput: (val) => { + if (val.length > 0 && !this.isExpanded && this.selectDOM) { + ;(this.selectDOM.dom as HTMLElement).click() + this.isExpanded = true + } + + attrs.search.search(val).then(() => { + const searchResult = attrs.search.results() + + if (searchResult.length === 0) { + this.selected = undefined + } + + this.options( + searchResult.map((option) => ({ + name: option.value.name, + value: option, + type: option.type, + ariaValue: option.value.name, + })), + ) + + m.redraw() + }) + + // if the new text length is more than one character longer, + // it means the user pasted the text in, so we want to try and resolve a list of contacts + const { remainingText, newRecipients, errors } = val.length - this.value.length > 1 ? parsePastedInput(val) : parseTypedInput(val) + + for (const { address, name } of newRecipients) { + attrs.onRecipientAdded(address, name, null) + } + + if (errors.length === 1 && newRecipients.length === 0) { + // if there was a single recipient and it was invalid then just pretend nothing happened + this.value = getFirstOrThrow(errors) + } else { + if (errors.length > 0) { + Dialog.message(() => `${lang.get("invalidPastedRecipients_msg")}\n\n${errors.join("\n")}`) + } + this.value = remainingText + } + }, + disabled: attrs.disabled, + ariaLabel: attrs.ariaLabel, + onfocus: (event: FocusEvent) => { + this.isFocused = true + }, + onblur: (e: any) => { + if (this.isFocused) { + this.resolveInput(attrs, false) + this.isFocused = false + } + + e.redraw = false + }, + onkeydown: (event: KeyboardEvent) => this.handleKeyDown(event, attrs), + }) + } + + private handleKeyDown(event: KeyboardEvent, attrs: GuestPickerAttrs) { + const keyPress = keyboardEventToKeyPress(event) + + switch (keyPress.key.toLowerCase()) { + case Keys.RETURN.code: + this.resolveInput(attrs, true) + break + case Keys.DOWN.code: + this.moveSelection(true) + event.stopImmediatePropagation() + return false + case Keys.UP.code: + this.moveSelection(false) + event.stopImmediatePropagation() + return false + } + + return true + } + + private moveSelection(forward: boolean) { + const selectedIndex = this.selected ? this.options().indexOf(this.selected) : -1 + const optionsLength = this.options().length + + let newIndex + if (forward) { + newIndex = selectedIndex + 1 < optionsLength ? selectedIndex + 1 : 0 + } else { + newIndex = selectedIndex - 1 >= 0 ? selectedIndex - 1 : optionsLength - 1 + } + + this.selected = this.options()[newIndex] + } + + private async selectSuggestion(attrs: GuestPickerAttrs) { + if (this.selected == null) { + return + } + + if (this.selected.value.type === "recipient") { + const { address, name, contact } = this.selected.value.value + attrs.onRecipientAdded(address, name, contact) + attrs.search.clear() + this.value = "" + } else { + attrs.search.clear() + this.value = "" + const recipients = await attrs.search.resolveContactList(this.selected.value.value) + for (const { address, name, contact } of recipients) { + attrs.onRecipientAdded(address, name, contact) + } + m.redraw() + } + + this.closePicker() + } + + /** + * Resolves a typed in mail address or one of the suggested ones. + * @param attrs + * @param selectSuggestion boolean value indicating whether a suggestion should be selected or not. Should be true if a suggestion is explicitly selected by + * for example hitting the enter key and false e.g. if the dialog is closed + */ + private resolveInput(attrs: GuestPickerAttrs, selectSuggestion: boolean) { + const suggestions = attrs.search.results() + if (suggestions.length > 0 && selectSuggestion) { + this.selectSuggestion(attrs) + } else { + const parsed = parseMailAddress(this.value) + if (parsed != null) { + attrs.onRecipientAdded(parsed.address, parsed.name, null) + this.value = "" + this.closePicker() + } + } + } + + private closePicker() { + if (this.selectDOM) { + ;(this.selectDOM.state as SelectState).dropdownContainer?.onClose() + } + } +} diff --git a/src/calendar-app/calendar/gui/pickers/TimePicker.ts b/src/calendar-app/calendar/gui/pickers/TimePicker.ts index aebfb83af94..43b8b1d90bb 100644 --- a/src/calendar-app/calendar/gui/pickers/TimePicker.ts +++ b/src/calendar-app/calendar/gui/pickers/TimePicker.ts @@ -8,6 +8,7 @@ import { Select, SelectAttributes } from "../../../../common/gui/base/Select.js" import { SingleLineTextField } from "../../../../common/gui/base/SingleLineTextField.js" import { isApp } from "../../../../common/api/common/Env.js" import { px, size } from "../../../../common/gui/size.js" +import stream from "mithril/stream" export type TimePickerAttrs = { time: Time | null @@ -121,7 +122,7 @@ export class TimePicker implements Component { })) return m(Select, { - onChange: (newValue) => { + onchange: (newValue) => { if (this.value === newValue.value) { return } @@ -130,13 +131,13 @@ export class TimePicker implements Component { this.onSelected(attrs) m.redraw.sync() }, - onClose: () => { + onclose: () => { this.isExpanded = false }, selected: { value: this.value, name: this.value, ariaValue: this.value }, ariaLabel: attrs.ariaLabel, disabled: attrs.disabled, - options, + options: stream(options), noIcon: true, expanded: true, tabIndex: Number(TabIndex.Programmatic), diff --git a/src/common/gui/MailRecipientsTextField.ts b/src/common/gui/MailRecipientsTextField.ts index 3109079e48d..36f52f5ccfb 100644 --- a/src/common/gui/MailRecipientsTextField.ts +++ b/src/common/gui/MailRecipientsTextField.ts @@ -214,7 +214,7 @@ interface ParsedInput { * Parse a list of valid mail addresses separated by either a semicolon or a comma. * Invalid addresses will be returned as a separate list */ -function parsePastedInput(text: string): ParsedInput { +export function parsePastedInput(text: string): ParsedInput { const separator = text.indexOf(";") !== -1 ? ";" : "," const textParts = text.split(separator).map((part) => part.trim()) @@ -249,7 +249,7 @@ function parsePastedInput(text: string): ParsedInput { * invalid input gets returned in `remainingText`, `errors` is always empty * @param text */ -function parseTypedInput(text: string): ParsedInput { +export function parseTypedInput(text: string): ParsedInput { const lastCharacter = text.slice(-1) // on semicolon, comman or space we want to try to resolve the input text diff --git a/src/common/gui/PasswordInput.ts b/src/common/gui/PasswordInput.ts new file mode 100644 index 00000000000..75386225a5c --- /dev/null +++ b/src/common/gui/PasswordInput.ts @@ -0,0 +1,52 @@ +import m, { Children, ClassComponent, Vnode } from "mithril" +import { SingleLineTextField } from "./base/SingleLineTextField.js" +import { TextFieldType } from "./base/TextField.js" +import { IconButton } from "./base/IconButton.js" +import { ButtonSize } from "./base/ButtonSize.js" +import { Icons } from "./base/icons/Icons.js" +import { theme } from "./theme.js" +import { scaleToVisualPasswordStrength } from "../misc/passwords/PasswordUtils.js" +import { px, size } from "./size.js" +import { lang } from "../misc/LanguageViewModel.js" + +export interface PasswordInputAttributes { + ariaLabel: string + password: string + strength: number + oninput: (newValue: string) => unknown +} + +export class PasswordInput implements ClassComponent { + private showPassword: boolean = false + + view(vnode: Vnode): Children { + return m(".flex.flex-grow.full-width.justify-between.items-center.gap-vpad-sm", [ + m("div", { + style: { + width: px(size.icon_size_large), + height: px(size.icon_size_large), + border: `1px solid ${theme.content_button}`, + borderRadius: "50%", + background: `conic-gradient(from .25turn, ${theme.content_button} ${scaleToVisualPasswordStrength(vnode.attrs.strength)}%, transparent 0%)`, + }, + }), + m(SingleLineTextField, { + classes: ["flex-grow"], + ariaLabel: vnode.attrs.ariaLabel, + type: this.showPassword ? TextFieldType.Text : TextFieldType.Password, + value: vnode.attrs.password, + oninput: vnode.attrs.oninput, + style: { + padding: `${px(size.vpad_xsm)} ${px(size.vpad_small)}`, + }, + placeholder: lang.get("password_label"), + }), + m(IconButton, { + size: ButtonSize.Compact, + title: this.showPassword ? "concealPassword_action" : "revealPassword_action", + icon: this.showPassword ? Icons.NoEye : Icons.Eye, + click: () => (this.showPassword = !this.showPassword), + }), + ]) + } +} diff --git a/src/common/gui/base/Dropdown.ts b/src/common/gui/base/Dropdown.ts index 31078adb731..6ab66cfa17c 100644 --- a/src/common/gui/base/Dropdown.ts +++ b/src/common/gui/base/Dropdown.ts @@ -499,7 +499,13 @@ export function attachDropdown({ export const DROPDOWN_MARGIN = 4 -export function showDropdown(origin: PosRect, domDropdown: HTMLElement, contentHeight: number, contentWidth: number): Promise { +export function showDropdown( + origin: PosRect, + domDropdown: HTMLElement, + contentHeight: number, + contentWidth: number, + position?: "top" | "bottom", +): Promise { // |------------------| |------------------| |------------------| |------------------| // | | | | | | | | // | |-------| | | |-------| | | |-----------^ | | ^-----------| | @@ -527,7 +533,8 @@ export function showDropdown(origin: PosRect, domDropdown: HTMLElement, contentH let transformOrigin = "" let maxHeight - if (lowerSpace > upperSpace) { + const showBelow = (!position && lowerSpace > upperSpace) || position === "bottom" + if (showBelow) { // element is in the upper part of the screen, dropdown should be below the element transformOrigin += "top" domDropdown.style.top = bottomEdgeOfElement + "px" diff --git a/src/common/gui/base/Select.ts b/src/common/gui/base/Select.ts index 8411694577b..d8244d8d8da 100644 --- a/src/common/gui/base/Select.ts +++ b/src/common/gui/base/Select.ts @@ -1,15 +1,18 @@ import m, { Children, ClassComponent, Vnode, VnodeDOM } from "mithril" import { modal, ModalComponent } from "./Modal.js" import { assertNotNull } from "@tutao/tutanota-utils" -import { size } from "../size.js" +import { px, size } from "../size.js" import { Keys, TabIndex } from "../../api/common/TutanotaConstants.js" import { focusNext, focusPrevious, isKeyPressed, Shortcut } from "../../misc/KeyManager.js" import { type PosRect, showDropdown } from "./Dropdown.js" -import { lang, TranslationKey } from "../../misc/LanguageViewModel.js" +import { lang } from "../../misc/LanguageViewModel.js" import { Icon, IconSize } from "./Icon.js" import { ButtonColor, getColors } from "./Button.js" import { Icons } from "./icons/Icons.js" import { AriaRole } from "../AriaUtils.js" +import Stream from "mithril/stream" +import { getSafeAreaInsetBottom, getSafeAreaInsetTop } from "../HtmlUtils.js" +import { theme } from "../theme.js" export interface SelectOption { // Here we declare everything that is important to use at the select option @@ -18,8 +21,8 @@ export interface SelectOption { } export interface SelectAttributes, T> { - onChange: (newValue: U) => void - options: Array + onchange: (newValue: U) => void + options: Stream> /** * This attribute is responsible to render the options inside the dropdown. * @example @@ -46,7 +49,7 @@ export interface SelectAttributes, T> { id?: string classes?: Array selected?: U - placeholder?: TranslationKey + placeholder?: Children expanded?: boolean disabled?: boolean noIcon?: boolean @@ -61,11 +64,17 @@ export interface SelectAttributes, T> { iconColor?: string keepFocus?: boolean tabIndex?: number - onClose?: () => void + onclose?: () => void + oncreate?: (...args: any[]) => unknown + dropdownPosition?: "top" | "bottom" } type HTMLElementWithAttrs = Partial & Omit & SelectAttributes, unknown>> +export interface SelectState { + dropdownContainer?: OptionListContainer +} + /** * Select component * @see Component attributes: {SelectAttributes} @@ -96,10 +105,11 @@ type HTMLElementWithAttrs = Partial & Omit, T> implements ClassComponent> { private isExpanded: boolean = false + private dropdownContainer?: OptionListContainer view({ attrs: { - onChange, + onchange, options, renderOption, renderDisplay, @@ -114,9 +124,10 @@ export class Select, T> implements ClassComponent>) { + }: Vnode, this>) { return m( "button.tutaui-select-trigger.clickable", { @@ -124,7 +135,16 @@ export class Select, T> implements ClassComponent event.target && - this.renderDropdown(options, event.target as HTMLElement, onChange, renderOption, keepFocus ?? false, selected?.value, onClose), + this.renderDropdown( + options, + event.target as HTMLElement, + onchange, + renderOption, + keepFocus ?? false, + selected?.value, + onclose, + dropdownPosition, + ), role: AriaRole.Combobox, ariaLabel, disabled: disabled, @@ -164,30 +184,37 @@ export class Select, T> implements ClassComponent, + options: Stream>, dom: HTMLElement, onSelect: (option: U) => void, renderOptions: (option: U) => Children, keepFocus: boolean, selected?: T, onClose?: () => void, + dropdownPosition?: "top" | "bottom", ) { const optionListContainer: OptionListContainer = new OptionListContainer( - options.map((option) => - m.fragment( + options, + (option: U) => { + return m.fragment( { oncreate: ({ dom }: VnodeDOM) => this.setupOption(dom as HTMLElement, onSelect, option, optionListContainer, selected), }, [renderOptions(option)], - ), - ), + ) + }, dom.getBoundingClientRect().width, keepFocus, + dropdownPosition, ) optionListContainer.onClose = () => { @@ -199,6 +226,7 @@ export class Select, T> implements ClassComponent>, + private readonly buildFunction: (option: unknown) => Children, + width: number, + keepFocus: boolean, + dropdownPosition?: "top" | "bottom", + ) { this.width = width this.shortcuts = this.buildShortcuts @@ -280,7 +314,7 @@ class OptionListContainer implements ModalComponent { // The maxHeight is available after the first onupdate call. Then this promise will resolve and we can safely // show the dropdown. // Modal always schedules redraw in oncreate() of a component so we are guaranteed to have onupdate() call. - showDropdown(this.origin, assertNotNull(this.domDropdown), this.maxHeight, this.width).then(() => { + showDropdown(this.origin, assertNotNull(this.domDropdown), this.maxHeight, this.width, dropdownPosition).then(() => { const selectedOption = vnode.dom.querySelector("[aria-selected='true']") as HTMLElement | null if (selectedOption && !keepFocus) { selectedOption.focus() @@ -289,6 +323,8 @@ class OptionListContainer implements ModalComponent { } }) } + } else { + this.updateDropdownSize(vnode) } }, onscroll: (ev: EventRedraw) => { @@ -298,12 +334,32 @@ class OptionListContainer implements ModalComponent { this.domContents != null && target.scrollTop < 0 && target.scrollTop + this.domContents.offsetHeight > target.scrollHeight }, }, - this.children, + this.items().length === 0 ? this.renderNoItem() : this.items().map((item) => this.buildFunction(item)), ), ) } } + private updateDropdownSize(vnode: VnodeDOM) { + if (!(this.origin && this.domDropdown)) { + return + } + + const upperSpace = this.origin.top - getSafeAreaInsetTop() + const lowerSpace = window.innerHeight - this.origin.bottom - getSafeAreaInsetBottom() + + const children = Array.from(vnode.dom.children) as Array + const contentHeight = Math.min(400 + size.vpad, children.reduce((accumulator, children) => accumulator + children.offsetHeight, 0) + size.vpad) + + this.maxHeight = lowerSpace > upperSpace ? Math.min(contentHeight, lowerSpace) : Math.min(contentHeight, upperSpace) + + this.domDropdown.style.height = px(this.maxHeight) + } + + private renderNoItem(): Children { + return m("span.placeholder.text-center", { color: theme.list_message_bg }, lang.get("noEntries_msg")) + } + backgroundClick = (e: MouseEvent) => { if ( this.domDropdown && diff --git a/src/common/gui/base/SingleLineTextField.ts b/src/common/gui/base/SingleLineTextField.ts index ca93009f4d3..157d0b0d13a 100644 --- a/src/common/gui/base/SingleLineTextField.ts +++ b/src/common/gui/base/SingleLineTextField.ts @@ -1,5 +1,7 @@ import m, { Children, ClassComponent, Component, Vnode, VnodeDOM } from "mithril" import type { TextFieldType } from "./TextField.js" +import { AllIcons, Icon, IconSize } from "./Icon.js" +import { px, size } from "../size.js" export interface SingleLineTextFieldAttrs extends Pick { value: string @@ -24,6 +26,10 @@ export interface SingleLineTextFieldAttrs extends Pick { onblur?: (...args: unknown[]) => unknown onkeydown?: (...args: unknown[]) => unknown type?: TextFieldType + leadingIcon?: { + icon: AllIcons + color: string + } } type HTMLElementWithAttrs = Partial & Omit & SingleLineTextFieldAttrs> @@ -58,6 +64,44 @@ export class SingleLineTextField implements ClassComponent): Children | void | null { + return attrs.leadingIcon ? this.renderInputWithIcon(attrs) : this.renderInput(attrs) + } + + private renderInputWithIcon(attrs: SingleLineTextFieldAttrs) { + if (!attrs.leadingIcon) { + return + } + + const fontSize = Number(attrs.style?.fontSize?.replace("px", "")) ?? 16 + let iconSize + let padding + + if (fontSize > 16 && fontSize < 32) { + iconSize = IconSize.Large + padding = size.icon_size_large + } else if (fontSize > 32) { + iconSize = IconSize.XL + padding = size.icon_size_xl + } else { + iconSize = IconSize.Medium + padding = size.icon_size_medium_large + } + + return m(".rel.flex.flex-grow", [ + m( + ".abs.pl-vpad-s.flex.items-center", + { style: { top: 0, bottom: 0 } }, + m(Icon, { + size: iconSize, + icon: attrs.leadingIcon.icon, + style: { fill: attrs.leadingIcon.color }, + }), + ), + this.renderInput(attrs, px(padding + size.vpad)), + ]) + } + + private renderInput(attrs: SingleLineTextFieldAttrs, inputPadding?: string) { return m("input.tutaui-text-field", { ariaLabel: attrs.ariaLabel, value: attrs.value, @@ -75,7 +119,11 @@ export class SingleLineTextField implements ClassComponent { border: "none", color: "transparent", }, + ".min-h-s": { + "min-height": px(size.vpad_xl * 4), + }, } })