mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Merge fix/compose-editor-polish: in-app popovers, and a picker that fits
Replaces the native prompt and confirm with popovers: a link editor with URL and text prefilled from the selection, a link viewer with Open/Change/Remove, and a plain-text warning. Preset times now honour the user's 12/24 setting, which formatWallClock never consulted. Two real display bugs behind the emoji picker's black borders: --rgb-line was spent without its alpha, so every hairline resolved to pure black, and the component set color-scheme from the OS rather than the app theme. Also fixes a more-options menu that was clipped to nothing on a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
cf9ac15e35
17 files changed
+1745
-36
No files matched your search
@@ -0,0 +1,138 @@
|
||||
{#
|
||||
compose/_link_popover.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The insert-link popover, for any surface driven by `compose--compose-toolbar`.
|
||||
|
||||
What it replaces is `prompt('Enter URL:')` — one unthemed field, no way to say
|
||||
what the link should READ as, and a bare "example.com" going in as a relative
|
||||
link to a path inside the app.
|
||||
|
||||
ONE element with two faces rather than two popovers. They are the same object
|
||||
at two moments — "what is this link" and "what should this link be" — and
|
||||
Change is a switch between them rather than a different window. The controller
|
||||
drives both; it is also what holds the saved selection the URL is applied to,
|
||||
which is why focusing a field in here does not lose the user's place. See the
|
||||
note on #savedRange in compose_toolbar_controller.js.
|
||||
|
||||
A partial rather than markup in the compose window, because the settings
|
||||
signature editor runs the SAME controller and had the same link button — and
|
||||
therefore had the same prompt. One popover, two hosts, no second copy to
|
||||
forget when one of them changes.
|
||||
|
||||
The POSITIONING is the caller's, passed in as `class`: the compose window
|
||||
anchors it above a toolbar block that spans the window and never scrolls (the
|
||||
button itself lives in a sideways scroller and would drag the popover off
|
||||
screen with it); the signature editor anchors it under its own small bar.
|
||||
|
||||
Variables:
|
||||
id string unique suffix for the two field ids
|
||||
class string positioning and width classes for the popover box
|
||||
#}
|
||||
<div data-compose--compose-toolbar-target="linkPopover" hidden
|
||||
role="dialog"
|
||||
aria-label="{{ 'compose.link.dialog'|trans }}"
|
||||
data-action="keydown->compose--compose-toolbar#linkKeydown"
|
||||
class="{{ class|default('absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80') }}
|
||||
z-40 popover p-3 text-sm">
|
||||
|
||||
{# ── Editing face ──────────────────────────────────────────── #}
|
||||
<div data-compose--compose-toolbar-target="linkEdit" class="space-y-2">
|
||||
<p data-compose--compose-toolbar-target="linkHeading"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'compose.link.add'|trans }}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="link-url-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
|
||||
{{ 'compose.link.url_label'|trans }}
|
||||
</label>
|
||||
{# `text`, not `url`: a bare "example.com" is what people type and
|
||||
is exactly what the controller normalises to https://example.com.
|
||||
type=url would have the browser refuse it before we saw it. #}
|
||||
<input type="text" inputmode="url"
|
||||
id="link-url-{{ id }}"
|
||||
data-compose--compose-toolbar-target="linkUrl"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="{{ 'compose.link.url_placeholder'|trans }}"
|
||||
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
|
||||
text-sm text-ink placeholder:text-ink-faint outline-none
|
||||
focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="link-text-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
|
||||
{{ 'compose.link.text_label'|trans }}
|
||||
</label>
|
||||
<input type="text"
|
||||
id="link-text-{{ id }}"
|
||||
data-compose--compose-toolbar-target="linkText"
|
||||
autocomplete="off"
|
||||
placeholder="{{ 'compose.link.text_placeholder'|trans }}"
|
||||
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
|
||||
text-sm text-ink placeholder:text-ink-faint outline-none
|
||||
focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<p data-compose--compose-toolbar-target="linkError" hidden
|
||||
role="alert" class="text-xs text-danger"></p>
|
||||
|
||||
<div class="flex items-center gap-2 pt-0.5">
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#applyLink"
|
||||
class="rounded-full bg-accent text-white text-sm font-semibold
|
||||
px-3 py-1.5 hover:bg-white/10 transition-colors">
|
||||
{{ 'compose.link.apply'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#closeLink"
|
||||
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
|
||||
hover:bg-hover hover:text-ink transition-colors">
|
||||
{{ 'compose.link.cancel'|trans }}
|
||||
</button>
|
||||
{# Only while an existing link is being edited — there is nothing
|
||||
to unlink otherwise. #}
|
||||
<button type="button" hidden
|
||||
data-compose--compose-toolbar-target="linkUnlink"
|
||||
data-action="click->compose--compose-toolbar#removeLink"
|
||||
class="ml-auto text-xs text-danger hover:underline">
|
||||
{{ 'compose.link.remove'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Viewing face ──────────────────────────────────────────────
|
||||
A click in a contenteditable only places a caret, so a link the user
|
||||
just wrote is otherwise unreachable without sending the message first.
|
||||
This is the Gmail affordance: the address, and the three things you can
|
||||
do to it. #}
|
||||
<div data-compose--compose-toolbar-target="linkView" hidden class="space-y-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'compose.link.heading'|trans }}
|
||||
</p>
|
||||
{# A real anchor, so the browser's own "copy link address" and
|
||||
middle-click work on it. #}
|
||||
<a data-compose--compose-toolbar-target="linkHref"
|
||||
href="#" target="_blank" rel="noopener noreferrer"
|
||||
class="block truncate text-sm text-accent hover:underline"></a>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#openLink"
|
||||
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
|
||||
hover:text-ink transition-colors">
|
||||
{{ 'compose.link.open'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#changeLink"
|
||||
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
|
||||
hover:text-ink transition-colors">
|
||||
{{ 'compose.link.change'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#removeLink"
|
||||
class="px-2 py-1 rounded text-sm text-danger hover:bg-hover
|
||||
transition-colors">
|
||||
{{ 'compose.link.remove'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,16 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
|
||||
imageFailed: 'compose.image_insert_failed'|trans,
|
||||
noSignature: 'compose.signature_none'|trans,
|
||||
confirmPlainText: 'compose.confirm.plain_text'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# The toolbar controller's own vocabulary. Same arrangement and same
|
||||
reason as compose--compose's above: the link popover writes words
|
||||
onto the screen from JavaScript, and a string baked into the
|
||||
controller is a string that stays English in a German window. #}
|
||||
data-compose--compose-toolbar-i18n-value="{{ {
|
||||
linkAdd: 'compose.link.add'|trans,
|
||||
linkEdit: 'compose.link.edit'|trans,
|
||||
linkInvalid: 'compose.link.invalid'|trans,
|
||||
linkUnsafe: 'compose.link.unsafe'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# Every From option's signature, keyed by the same "accountId|address"
|
||||
token the hidden account <select> carries. Handed over in one go so
|
||||
@@ -417,7 +426,67 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
</div>
|
||||
|
||||
{# ── Toolbar ──────────────────────────────────────────── #}
|
||||
<div class="px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
|
||||
{# `relative` so the two popovers below can anchor to THIS block
|
||||
rather than to a button. Both the format bar and the icon row
|
||||
scroll sideways on a phone (.compose-scroll-x), and anything
|
||||
anchored to a control inside one of them opens wherever that
|
||||
control happens to have been scrolled to — which is how the emoji
|
||||
panel came to open past the right edge of the screen. This
|
||||
wrapper spans the window and never scrolls, so a popover pinned
|
||||
to it is on screen by construction at every width. #}
|
||||
<div class="relative px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
|
||||
|
||||
{# ── Link popover ─────────────────────────────────────
|
||||
Shared with the settings signature editor, which runs the
|
||||
same toolbar controller and had the same `prompt()`. The
|
||||
positioning is passed in because it is the one thing the two
|
||||
hosts disagree about: here it is anchored to the toolbar
|
||||
BLOCK, which spans the window and never scrolls — the link
|
||||
button itself lives in a sideways scroller and would drag
|
||||
the popover off the screen with it. #}
|
||||
{{ include('compose/_link_popover.html.twig', {
|
||||
id: form.vars.id,
|
||||
class: 'absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80',
|
||||
}, with_context = false) }}
|
||||
|
||||
{# ── Plain-text warning ───────────────────────────────
|
||||
Was `window.confirm()`. The rule it enforces has not changed
|
||||
— it only appears when there is formatting that flattening
|
||||
would actually destroy, and the switch stays reversible for
|
||||
as long as the draft has not been saved in plain mode (see
|
||||
_setPlainText) — but a native dialog is the one thing in this
|
||||
window the theme never reached, and on a phone it is a
|
||||
system sheet over an app that was mid-sentence. #}
|
||||
<div data-compose--compose-target="plainWarning" hidden
|
||||
role="alertdialog"
|
||||
aria-labelledby="{{ form.vars.id }}_plain_warning_title"
|
||||
aria-describedby="{{ form.vars.id }}_plain_warning_body"
|
||||
class="absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80
|
||||
z-40 popover p-3 text-sm">
|
||||
<p id="{{ form.vars.id }}_plain_warning_title"
|
||||
class="text-sm font-semibold text-ink">
|
||||
{{ 'compose.plain_warning.title'|trans }}
|
||||
</p>
|
||||
<p id="{{ form.vars.id }}_plain_warning_body"
|
||||
class="mt-1 text-xs text-ink-muted">
|
||||
{{ 'compose.confirm.plain_text'|trans }}
|
||||
</p>
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<button type="button"
|
||||
data-compose--compose-target="plainWarningConfirm"
|
||||
data-action="click->compose--compose#confirmPlainText"
|
||||
class="rounded-full bg-accent text-white text-sm font-semibold
|
||||
px-3 py-1.5 hover:bg-white/10 transition-colors">
|
||||
{{ 'compose.plain_warning.continue'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose#cancelPlainText"
|
||||
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
|
||||
hover:bg-hover hover:text-ink transition-colors">
|
||||
{{ 'compose.plain_warning.cancel'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# One line that scrolls sideways below md — wrapping it would
|
||||
eat three rows of a phone screen. The controller hides it
|
||||
@@ -730,8 +799,18 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
<input type="file" multiple class="hidden"
|
||||
data-compose--compose-target="fileInput"
|
||||
data-action="change->compose--compose#uploadFiles">
|
||||
{# `data-compose--compose-toolbar-action` is load-bearing
|
||||
here, not decoration: it is what the controller hangs a
|
||||
mousedown preventDefault on, and that is the only reason
|
||||
the editor keeps the user's selection while this button
|
||||
is pressed. Without it the selection collapses before
|
||||
the popover has had a chance to read it, and the text
|
||||
field prefills empty. #}
|
||||
<button type="button" data-compose--compose-toolbar-action
|
||||
data-compose--compose-toolbar-target="linkBtn"
|
||||
data-action="click->compose--compose-toolbar#insertLink"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
title="{{ 'compose.insert_link'|trans }}"
|
||||
aria-label="{{ 'compose.insert_link'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
|
||||
@@ -873,9 +952,29 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
aria-label="{{ 'compose.more_options'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical" aria-hidden="true"></i></button>
|
||||
|
||||
{# Below md this menu is pinned to the VIEWPORT rather
|
||||
than to its button, exactly as the emoji panel is and
|
||||
for exactly the same reason — except that here it was
|
||||
not merely mispositioned, it was unreachable. The
|
||||
button sits in `.compose-scroll-x`, whose
|
||||
`overflow-x: auto` computes `overflow-y: auto` as
|
||||
well, so a menu opening UPWARDS out of a 36px strip
|
||||
was clipped to nothing: on a phone the three controls
|
||||
in here (plain text, priority, read receipt) could be
|
||||
tapped open and never seen. A fixed element is not
|
||||
clipped by an overflow ancestor — its containing
|
||||
block is .compose-window, which below md is the
|
||||
screen — which is the whole trick.
|
||||
|
||||
`bottom-20` clears the action bar; `w-auto` releases
|
||||
the `w-64` so left-2/right-2 can set the width.
|
||||
compose-mobile.spec.ts measures the box against the
|
||||
viewport at 393px and at 320px. #}
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
class="absolute bottom-full mb-1 left-0 md:right-auto z-20
|
||||
w-64 max-w-[calc(100vw-2rem)] popover py-1">
|
||||
class="absolute bottom-full mb-1 left-0 md:right-auto z-30
|
||||
w-64 max-w-[calc(100vw-2rem)] popover py-1
|
||||
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
|
||||
max-md:bottom-20 max-md:w-auto max-md:max-w-none">
|
||||
|
||||
{# Plain text. A toggle, not a submit: what it
|
||||
changes is which surface the body lives in, and
|
||||
@@ -950,8 +1049,14 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
title="{{ 'integration.picker.attach_from_service'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-cloud-arrow-down"></i></button>
|
||||
{# Pinned to the viewport below md, same as the more-
|
||||
options menu above and for the same reason: this
|
||||
button is in the sideways scroller too, and a
|
||||
menu opening upwards out of it is clipped away. #}
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-20 min-w-48 popover py-1">
|
||||
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-30 min-w-48 popover py-1
|
||||
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
|
||||
max-md:bottom-20 max-md:min-w-0">
|
||||
{% for integration in pickerIntegrations %}
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
|
||||
Reference in new issue
Block a user