mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Bound the split, reach every view from the pane, and polish the grids
Six things, all from looking at it rather than at the code:
* idx_ceo_span is declared on the entity now, as a plain index, and still
created USING gist by the migration. That is the trick Message already
plays with idx_message_search_vector, which is really GIN: Doctrine
matches an index on name and columns and never looks at the method. The
schema diff is empty again, so the earlier "never run schema:update"
warning is gone from todo.md with it.
* The pane had no way to reach the month. The view switcher was simply
dropped in compact mode, which left the docked calendar stuck on its
agenda forever; it is there now as icons, which is what fits at 380px.
* The drag had one bound and needed two. It clamped the pane against half
the ROW, counting the sidebar as space the panes had, so on a narrower
screen the mail list could be squeezed to a column of truncated
subjects. It now measures what the two panes actually share and keeps
420px on the mail side. Where both minimums cannot be met the mail wins:
it is what the app is for, and the pane can be closed.
* That also moves the dock to lg. Below 1024px there is no arrangement
where a sidebar, a usable list and a usable calendar all fit, so it does
not dock at all and the sidebar link opens the page instead.
* The handle sat off-centre. The row's own gap-gutter falls to the left of
the wrapper, so a one-gutter handle put its grip 1.5 gutters from the
mail and 0.5 from the calendar. It spans the whole gap now.
* Grids fill their height, today is a filled circle, weekends and
spill-in days are tinted, and chips are one line — colour, time, title.
Chips were centred because a <button> centres its text and the earlier
fix from <a> to <button> did not account for that.
Persistence coalesces rather than chains. Chaining ordered the writes but
made the newest one — the one the user actually made — wait behind three
stale ones, and it was the first thing lost on navigation.
Per-day add buttons name their day, so a week is not eight buttons all
called "New event" to a screen reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
07570fa7c8
commit
90625192cc
16 files changed
+481
-168
No files matched your search
Generated
+3
@@ -207,6 +207,9 @@
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabre/vobject" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabre/uri" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabre/xml" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
|
||||
|
||||
Generated
+3
@@ -195,6 +195,9 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpstan-doctrine" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpstan-symfony" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/uri" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/vobject" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/xml" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
@@ -17,15 +17,26 @@ import { Controller } from "@hotwired/stimulus";
|
||||
* width is already correct locally — the round trip is for the next page load
|
||||
* and the user's other devices, not for this one.
|
||||
*/
|
||||
/* Below this the row cannot hold a sidebar, a usable mail pane and a usable
|
||||
* calendar at once, so the pane does not dock at all and the trigger navigates
|
||||
* to the full page instead. Matches the `lg:` the wrapper is shown at — the two
|
||||
* have to agree or the toggle points at something that cannot appear. */
|
||||
const DOCK_BREAKPOINT = "(min-width: 1024px)";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["wrapper", "pane", "handle"];
|
||||
static targets = ["wrapper", "pane", "handle", "main"];
|
||||
static values = {
|
||||
stateUrl: String,
|
||||
token: String,
|
||||
min: { type: Number, default: 320 },
|
||||
max: { type: Number, default: 900 },
|
||||
// What the mail side is never dragged below. The pane's own minimum is
|
||||
// not enough on its own: without this the handle happily shrinks the
|
||||
// message list to a column of truncated subjects.
|
||||
mainMin: { type: Number, default: 420 },
|
||||
open: { type: Boolean, default: false },
|
||||
step: { type: Number, default: 24 },
|
||||
default: { type: Number, default: 380 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
@@ -43,6 +54,12 @@ export default class extends Controller {
|
||||
* navigation is this method's job.
|
||||
*/
|
||||
toggle(event) {
|
||||
// Narrower than the dock breakpoint there is nowhere to put it, so let
|
||||
// the trigger do what its href says and open the calendar as a page.
|
||||
if (!window.matchMedia(DOCK_BREAKPOINT).matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.openValue = !this.openValue;
|
||||
@@ -52,7 +69,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
this.wrapperTarget.classList.toggle("hidden", !this.openValue);
|
||||
this.wrapperTarget.classList.toggle("md:flex", this.openValue);
|
||||
this.wrapperTarget.classList.toggle("lg:flex", this.openValue);
|
||||
|
||||
// The frame is lazy and has no src until the pane is first opened, so
|
||||
// opening it is what loads the calendar. Turbo takes it from there.
|
||||
@@ -103,7 +120,7 @@ export default class extends Controller {
|
||||
|
||||
reset(event) {
|
||||
event.preventDefault();
|
||||
this._apply(380, { persist: true });
|
||||
this._apply(this.defaultValue, { persist: true });
|
||||
}
|
||||
|
||||
_onMove(event) {
|
||||
@@ -130,21 +147,45 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
/*
|
||||
* The upper bound is the smaller of the configured maximum and half the
|
||||
* row: a pane that can be dragged over the mail it sits beside is a pane
|
||||
* that can hide it entirely, with no obvious way back.
|
||||
* Bounded from both sides, against what the two panes actually share.
|
||||
*
|
||||
* The pane is a fixed width and the mail side is flex-1, so their combined
|
||||
* width is whatever the row has left after the sidebar and stays constant
|
||||
* as the boundary moves — which makes it the right thing to measure. The
|
||||
* earlier version bounded the pane at half the ROW, which counted the
|
||||
* sidebar as space the panes had and let the mail side be squeezed to a
|
||||
* column of truncated subjects on a narrower screen.
|
||||
*
|
||||
* Where the two minimums cannot both be met, the mail side wins: it is the
|
||||
* thing the app is for, and the pane can be closed.
|
||||
*/
|
||||
_apply(width, { persist = false } = {}) {
|
||||
const ceiling = Math.min(this.maxValue, this.element.getBoundingClientRect().width / 2);
|
||||
const combined = this._combinedWidth();
|
||||
const ceiling = Math.min(this.maxValue, combined - this.mainMinValue);
|
||||
const clamped = Math.max(this.minValue, Math.min(ceiling, width));
|
||||
|
||||
this.element.style.setProperty("--calendar-pane-width", `${Math.round(clamped)}px`);
|
||||
// Only true on a viewport too narrow to hold both, where clamping up to
|
||||
// the pane's minimum would eat into the mail side's.
|
||||
const bounded = ceiling < this.minValue ? Math.max(0, ceiling) : clamped;
|
||||
|
||||
this.element.style.setProperty("--calendar-pane-width", `${Math.round(bounded)}px`);
|
||||
|
||||
if (persist) {
|
||||
this._persist({ width: String(Math.round(clamped)) });
|
||||
this._persist({ width: String(Math.round(bounded)) });
|
||||
}
|
||||
}
|
||||
|
||||
_combinedWidth() {
|
||||
const pane = this.hasPaneTarget ? this.paneTarget.getBoundingClientRect().width : 0;
|
||||
const main = this.hasMainTarget ? this.mainTarget.getBoundingClientRect().width : 0;
|
||||
|
||||
// No main pane to measure (the calendar page itself) — fall back to the
|
||||
// row, which is the same thing minus a sidebar.
|
||||
return main > 0
|
||||
? main + pane
|
||||
: this.element.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
_currentWidth() {
|
||||
return this.hasPaneTarget
|
||||
? this.paneTarget.getBoundingClientRect().width
|
||||
@@ -152,36 +193,58 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
/*
|
||||
* Queued, not fired-and-forgotten.
|
||||
* Coalesced, not fired-and-forgotten and not queued one behind another.
|
||||
*
|
||||
* Several of these can be triggered in quick succession — a double-click to
|
||||
* reset emits pointerup twice before the reset itself, and a drag right
|
||||
* after it emits another. Sent concurrently they race, and the server keeps
|
||||
* whichever finished last rather than whichever the user did last, so the
|
||||
* pane comes back at a width nobody chose. Chaining makes the order the
|
||||
* user's order.
|
||||
* Several of these fire in quick succession — a double-click to reset emits
|
||||
* pointerup twice before the reset itself, and a drag right after emits
|
||||
* another. Sent concurrently they race, and the server keeps whichever
|
||||
* finished last rather than whichever the user did last, so the pane comes
|
||||
* back at a width nobody chose.
|
||||
*
|
||||
* Chaining fixes the order but makes it worse in the case that matters: the
|
||||
* newest write, the one the user actually made, waits behind three stale
|
||||
* ones and is the first thing lost if they navigate. So intermediate states
|
||||
* are dropped instead — only the latest is ever in flight or pending, which
|
||||
* is both correct and at most two requests.
|
||||
*
|
||||
* Still best-effort about failure: a write that does not land costs the
|
||||
* memory, never the layout, which is already correct locally.
|
||||
* memory, never the layout, which is already right locally.
|
||||
*/
|
||||
_persist(fields) {
|
||||
if (!this.hasStateUrlValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pending = { ...(this._pending ?? {}), ...fields };
|
||||
|
||||
if (!this._inFlight) {
|
||||
this._flush();
|
||||
}
|
||||
}
|
||||
|
||||
_flush() {
|
||||
const fields = this._pending;
|
||||
this._pending = null;
|
||||
|
||||
if (!fields) {
|
||||
this._inFlight = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append("_token", this.tokenValue);
|
||||
|
||||
Object.entries(fields).forEach(([key, value]) => body.append(key, value));
|
||||
|
||||
this._queue = (this._queue ?? Promise.resolve())
|
||||
.then(() =>
|
||||
fetch(this.stateUrlValue, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "X-Requested-With": "fetch" },
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
this._inFlight = fetch(this.stateUrlValue, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "X-Requested-With": "fetch" },
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this._inFlight = null;
|
||||
this._flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,14 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
* an `&&` overlap against a GiST index on it — see the repository for why that
|
||||
* beats comparing starts_at and ends_at separately.
|
||||
*
|
||||
* That index (idx_ceo_span) exists only in the migration: this DBAL has no flag
|
||||
* for `USING gist`, so the mapping cannot express it and every schema diff asks
|
||||
* to drop it. Nothing in this project runs doctrine:schema:update — the
|
||||
* entrypoint migrates — and it must stay that way, because dropping it turns
|
||||
* every calendar view into a sequential scan without failing anything.
|
||||
* idx_ceo_span is declared below as a plain index and created `USING gist` by
|
||||
* the migration. That is deliberate and is the same trick Message plays with
|
||||
* idx_message_search_vector, which is really GIN: Doctrine's comparator matches
|
||||
* an index on its name and columns and never looks at the method, so declaring
|
||||
* it keeps the mapping and the database agreeing while the migration decides
|
||||
* how it is actually built. Without the declaration every schema diff asks to
|
||||
* drop it, and dropping it turns every calendar view into a sequential scan
|
||||
* without failing anything.
|
||||
*
|
||||
* Non-recurring events get exactly one row here too. One code path for reads is
|
||||
* worth more than the rows saved by special-casing them.
|
||||
@@ -45,6 +48,8 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Table(name: 'calendar_event_occurrence')]
|
||||
#[ORM\Index(name: 'idx_ceo_usr_starts', columns: ['usr_id', 'starts_at'])]
|
||||
#[ORM\Index(name: 'idx_ceo_calendar_starts', columns: ['calendar_id', 'starts_at'])]
|
||||
// Built `USING gist` by the migration — see the note above.
|
||||
#[ORM\Index(name: 'idx_ceo_span', columns: ['calendar_id', 'span'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_ceo_event_recurrence', columns: ['event_id', 'recurrence_id'])]
|
||||
class CalendarEventOccurrence
|
||||
{
|
||||
|
||||
@@ -77,15 +77,24 @@ Pagination variables expected from the controller (all optional, fallback to 0/1
|
||||
{% include '_partials/_sidebar.html.twig' %}
|
||||
</div>
|
||||
|
||||
{# Main content #}
|
||||
<div class="flex flex-1 overflow-hidden relative main-pane">
|
||||
{# Main content. Targeted by ui--split so the drag can enforce a
|
||||
minimum on this side too — the pane knows how much room it is
|
||||
taking only by measuring what is left. #}
|
||||
<div class="flex flex-1 overflow-hidden relative main-pane"
|
||||
{% if app.user %}data-ui--split-target="main"{% endif %}>
|
||||
|
||||
{# List pane #}
|
||||
<div
|
||||
id="message-list"
|
||||
data-sync-scope="{% block list_scope %}*{% endblock %}"
|
||||
data-mail--mail-pane-target="list"
|
||||
class="flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
|
||||
{# @container, so the rows below switch to their stacked
|
||||
layout on the LIST's width. They used to switch on the
|
||||
viewport's, which is a different number entirely once
|
||||
the calendar pane is docked: a 1440px window with a
|
||||
wide pane leaves the list narrower than a phone, and
|
||||
every row stayed on one line and truncated to nothing. #}
|
||||
class="@container flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
|
||||
>
|
||||
<turbo-frame id="inbox-list-frame" data-turbo-action="advance" class="contents">
|
||||
|
||||
@@ -124,7 +133,7 @@ Pagination variables expected from the controller (all optional, fallback to 0/1
|
||||
|
||||
{# Calendar — a sibling of .main-pane, so the handle between
|
||||
them splits the row's width rather than shrinking one into
|
||||
the other. Below md it is absent and the sidebar link goes
|
||||
the other. Below lg it is absent and the sidebar link goes
|
||||
to the full page instead. #}
|
||||
{% if app.user %}
|
||||
{% include '_partials/_calendar_pane.html.twig' %}
|
||||
|
||||
@@ -23,15 +23,27 @@ than one that does not remember at all.
|
||||
{# The controller lives on the row in _mailbox.html.twig, not here: it owns the
|
||||
boundary between two panes, and its scope has to reach the sidebar's toggle
|
||||
as well as this. Visibility is one conditional class rather than
|
||||
`md:flex md:hidden` — those two are the same breakpoint and which wins comes
|
||||
down to CSS source order, which is not something to rely on. #}
|
||||
`lg:flex lg:hidden` — those two are the same breakpoint and which wins comes
|
||||
down to CSS source order, which is not something to rely on.
|
||||
|
||||
lg, not md. Below 1024px the row cannot hold a sidebar, a usable mail pane
|
||||
and a usable calendar at once: something has to be squeezed under its
|
||||
minimum, and the honest answer is not to dock at all. Between md and lg the
|
||||
sidebar's Calendar entry navigates to the full page instead — ui--split#toggle
|
||||
checks the same breakpoint and steps out of the way. #}
|
||||
<div
|
||||
data-ui--split-target="wrapper"
|
||||
class="{{ paneOpen ? 'hidden md:flex' : 'hidden' }} shrink-0"
|
||||
class="{{ paneOpen ? 'hidden lg:flex' : 'hidden' }} shrink-0"
|
||||
>
|
||||
{# The handle. role=separator with aria-orientation is what makes it
|
||||
reachable and meaningful without a mouse; the arrow keys are handled by
|
||||
the controller. #}
|
||||
the controller.
|
||||
|
||||
Two gutters wide and pulled left by one, so it spans the whole visual
|
||||
space between the panes rather than sitting to one side of it. The row's
|
||||
own gap-gutter falls to the LEFT of this wrapper, so a handle that was
|
||||
merely one gutter wide put its grip 1.5 gutters from the mail pane and
|
||||
0.5 from the calendar — visibly off-centre, and a thin target. #}
|
||||
<div
|
||||
data-ui--split-target="handle"
|
||||
data-action="pointerdown->ui--split#start keydown->ui--split#nudge dblclick->ui--split#reset"
|
||||
@@ -39,7 +51,8 @@ than one that does not remember at all.
|
||||
aria-orientation="vertical"
|
||||
aria-label="{{ 'calendar.pane.resize'|trans }}"
|
||||
tabindex="0"
|
||||
class="group relative w-gutter shrink-0 cursor-col-resize
|
||||
class="group relative -ml-gutter w-[calc(var(--spacing-gutter)*2)] shrink-0
|
||||
cursor-col-resize
|
||||
flex items-center justify-center
|
||||
focus:outline-none"
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ Variables:
|
||||
#}
|
||||
{% set chipLabels = item.labels|filter(chipLabel => chipLabel.role is null and chipLabel.isVisible) %}
|
||||
{% if chipLabels|length > 0 %}
|
||||
<span class="hidden sm:inline-flex items-center gap-1 mr-0.5 shrink-0 align-middle">
|
||||
<span class="hidden @lg:inline-flex items-center gap-1 mr-0.5 shrink-0 align-middle">
|
||||
{% for chipLabel in chipLabels|slice(0, 3) %}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-px rounded text-[11px] font-medium max-w-28
|
||||
bg-sunken text-ink-soft">
|
||||
|
||||
@@ -118,15 +118,16 @@ the conversation it answers.
|
||||
</button>
|
||||
|
||||
{# Content — switches between mobile (2-row) and desktop (1-row) #}
|
||||
<div class="flex-1 min-w-0 flex flex-col md:flex-row md:items-center md:gap-4">
|
||||
<div class="flex-1 min-w-0 flex flex-col @xl:flex-row @xl:items-center @xl:gap-4">
|
||||
|
||||
{# Row 1 (mobile) / sender column (desktop) #}
|
||||
<div class="flex items-center justify-between md:justify-start md:w-40 md:flex-shrink-0">
|
||||
<div class="flex items-center justify-between @xl:justify-start @xl:w-40 @xl:flex-shrink-0">
|
||||
<span class="truncate text-sm {{ unread ? 'font-semibold text-gray-900 dark:text-gray-100' : 'font-normal text-gray-600 dark:text-gray-400' }}">
|
||||
{{ sender }}
|
||||
</span>
|
||||
{# Date — mobile only (top-right), hidden on desktop #}
|
||||
<span class="md:hidden flex-shrink-0 ml-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
<span data-row-date="stacked"
|
||||
class="@xl:hidden flex-shrink-0 ml-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
{% if date %}
|
||||
{% if date|date('Y-m-d') == 'now'|date('Y-m-d') %}
|
||||
{{ date|date('g:i a') }}
|
||||
@@ -153,7 +154,8 @@ the conversation it answers.
|
||||
</div>
|
||||
|
||||
{# Date + hover actions slot — desktop only #}
|
||||
<div class="hidden md:flex flex-shrink-0 w-36 relative h-full items-center">
|
||||
<div data-row-date="inline"
|
||||
class="hidden @xl:flex flex-shrink-0 w-36 relative h-full items-center">
|
||||
|
||||
<span class="absolute inset-0 flex items-center justify-end group-hover:invisible text-xs text-gray-400 dark:text-gray-600">
|
||||
{% if hasAttachment %}
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
{#
|
||||
One occurrence, as it appears in any view.
|
||||
|
||||
A single line: the calendar's colour, the time, then the title taking whatever
|
||||
is left. Time first because in a dense grid that is the column you scan — the
|
||||
title is what you read once you have found the row. `g:i a` matches how the
|
||||
mail list writes times; the grid drops the meridiem to save the width and keeps
|
||||
it in the tooltip.
|
||||
|
||||
`text-left` is not decoration. This is a <button>, and a button centres its text
|
||||
by default, which is what had every chip in the month grid sitting oddly
|
||||
mid-cell.
|
||||
|
||||
Opens the editor in the body-level modal frame — never inside the calendar
|
||||
pane, which carries backdrop-filter and would clip a fixed-position dialog to
|
||||
itself.
|
||||
itself. The controller has to be on the trigger: a Stimulus action only reaches
|
||||
controllers mounted on an ancestor, and the modal's lives on #modal-backdrop.
|
||||
#}
|
||||
|
||||
{% set event = occurrence.event %}
|
||||
{% set showTime = showTime|default(true) and not event.isAllDay %}
|
||||
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_edit', { id: event.id }) }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.title'|trans }}"
|
||||
class="group flex items-start gap-1.5 px-1.5 py-1 rounded-md text-xs
|
||||
title="{{ event.title }}{{ showTime ? ' · ' ~ occurrence.startsAt|date('g:i a') : '' }}"
|
||||
class="w-full text-left flex items-center gap-1.5
|
||||
px-1.5 py-0.5 rounded-md
|
||||
hover:bg-hover transition-colors min-w-0
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent
|
||||
{{ occurrence.cancelled ? 'opacity-50' : '' }}"
|
||||
title="{{ event.title }}"
|
||||
>
|
||||
<span class="mt-1 w-1.5 h-1.5 rounded-full shrink-0"
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style="background-color: {{ event.calendar.color }}"
|
||||
aria-hidden="true"></span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate {{ occurrence.cancelled ? 'line-through' : '' }}">
|
||||
{%- if event.kind is not null -%}
|
||||
<i class="{{ event.kind.icon }} mr-1 text-[10px] text-ink-faint" aria-hidden="true"></i>
|
||||
{%- endif -%}
|
||||
{{ event.title }}
|
||||
{% if showTime %}
|
||||
<span class="shrink-0 text-[11px] tabular-nums text-ink-faint">
|
||||
{{ occurrence.startsAt|date('g:i') }}
|
||||
</span>
|
||||
{% if not event.isAllDay %}
|
||||
<span class="block text-[11px] text-ink-faint">
|
||||
{{ occurrence.startsAt|date('H:i') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-ink
|
||||
{{ occurrence.cancelled ? 'line-through' : '' }}">
|
||||
{%- if event.kind is not null -%}
|
||||
<i class="{{ event.kind.icon }} mr-1 text-[10px] text-ink-faint" aria-hidden="true"></i>
|
||||
{%- endif -%}
|
||||
{{ event.title }}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1,64 +1,96 @@
|
||||
{#
|
||||
The row above the grid: where you are, how to move, and which view.
|
||||
|
||||
compact is set by the docked pane, which has room for the date and the arrows
|
||||
and nothing else. The view switcher is a link per view rather than a select,
|
||||
because a view is a URL — every one of these is bookmarkable and back works.
|
||||
Every control is a link, because a view is a URL — each one is bookmarkable,
|
||||
back works, and the range stays a single indexed query.
|
||||
|
||||
compact is set by the docked pane. It gets the same four views, as icons rather
|
||||
than words: the pane is ~380px and "Day Week Month Agenda" spelled out leaves no
|
||||
room for the date, but dropping the switcher entirely meant the pane could only
|
||||
ever show its agenda — there was no way to reach the month from it at all.
|
||||
#}
|
||||
|
||||
{% set compact = compact|default(false) %}
|
||||
{% set frame = frame|default(null) %}
|
||||
{% set paneQuery = compact ? '?pane=1' : '' %}
|
||||
|
||||
{% set prevDate = view.value == 'month'
|
||||
? anchor|date_modify('-1 month')|date('Y-m-d')
|
||||
: (view.value == 'week' ? anchor|date_modify('-7 days')|date('Y-m-d') : anchor|date_modify('-1 day')|date('Y-m-d')) %}
|
||||
{% set nextDate = view.value == 'month'
|
||||
? anchor|date_modify('+1 month')|date('Y-m-d')
|
||||
: (view.value == 'week' ? anchor|date_modify('+7 days')|date('Y-m-d') : anchor|date_modify('+1 day')|date('Y-m-d')) %}
|
||||
{# Agenda steps a day at a time like day view: it is a rolling list, not a page. #}
|
||||
{% set step = view.value == 'month' ? '1 month' : (view.value == 'week' ? '7 days' : '1 day') %}
|
||||
{% set prevDate = anchor|date_modify('-' ~ step)|date('Y-m-d') %}
|
||||
{% set nextDate = anchor|date_modify('+' ~ step)|date('Y-m-d') %}
|
||||
|
||||
{% set viewIcons = {
|
||||
day: 'fa-calendar-day',
|
||||
week: 'fa-calendar-week',
|
||||
month: 'fa-calendar-days',
|
||||
agenda: 'fa-list-ul',
|
||||
} %}
|
||||
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-line shrink-0">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: prevDate }) }}{{ compact ? '?pane=1' : '' }}"
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: prevDate }) }}{{ paneQuery }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-ink-muted hover:bg-hover hover:text-ink transition-colors"
|
||||
aria-label="{{ 'calendar.nav.previous'|trans }}"
|
||||
><i class="fa-solid fa-chevron-left text-xs" aria-hidden="true"></i></a>
|
||||
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: 'now'|date('Y-m-d') }) }}{{ compact ? '?pane=1' : '' }}"
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: 'now'|date('Y-m-d') }) }}{{ paneQuery }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
class="px-2 h-7 flex items-center rounded-lg text-xs text-ink-muted hover:bg-hover hover:text-ink transition-colors"
|
||||
>{{ 'calendar.today'|trans }}</a>
|
||||
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: nextDate }) }}{{ compact ? '?pane=1' : '' }}"
|
||||
<a href="{{ path('app_calendar_view', { view: view.value, date: nextDate }) }}{{ paneQuery }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg text-ink-muted hover:bg-hover hover:text-ink transition-colors"
|
||||
aria-label="{{ 'calendar.nav.next'|trans }}"
|
||||
><i class="fa-solid fa-chevron-right text-xs" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
|
||||
<h2 class="text-sm font-medium text-ink truncate">
|
||||
{{ anchor|date(view.value == 'month' ? 'F Y' : 'j M Y') }}
|
||||
{# A week is a range, and saying "1 Aug 2026" over a grid running 27 Jul to
|
||||
2 Aug is just wrong. The year is dropped from the near end when both ends
|
||||
share it, and the month too when the week does not cross one. #}
|
||||
<h2 class="text-sm font-medium text-ink truncate min-w-0">
|
||||
{%- if view.value == 'month' -%}
|
||||
{{ anchor|date('F Y') }}
|
||||
{%- elseif view.value == 'week' -%}
|
||||
{% set weekStart = range.from %}
|
||||
{% set weekEnd = range.to|date_modify('-1 day') %}
|
||||
{{ weekStart|date(weekStart|date('Y') == weekEnd|date('Y')
|
||||
? (weekStart|date('n') == weekEnd|date('n') ? 'j' : 'j M')
|
||||
: 'j M Y') }}
|
||||
– {{ weekEnd|date('j M Y') }}
|
||||
{%- else -%}
|
||||
{{ anchor|date('j M Y') }}
|
||||
{%- endif -%}
|
||||
</h2>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{% if not compact %}
|
||||
<div class="flex items-center rounded-lg border border-line overflow-hidden">
|
||||
{% for candidate in enum('App\\Domain\\Enum\\Calendar\\CalendarView').cases() %}
|
||||
<a href="{{ path('app_calendar_view', { view: candidate.value, date: anchor|date('Y-m-d') }) }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
class="px-2.5 py-1 text-xs transition-colors
|
||||
{{ candidate == view ? 'bg-accent text-white' : 'text-ink-muted hover:bg-hover hover:text-ink' }}"
|
||||
>{{ ('calendar.view.' ~ candidate.value)|trans }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="ml-auto flex items-center gap-1 shrink-0">
|
||||
<div class="flex items-center rounded-lg border border-line overflow-hidden">
|
||||
{% for candidate in enum('App\\Domain\\Enum\\Calendar\\CalendarView').cases() %}
|
||||
{% set label = ('calendar.view.' ~ candidate.value)|trans %}
|
||||
<a href="{{ path('app_calendar_view', { view: candidate.value, date: anchor|date('Y-m-d') }) }}{{ paneQuery }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
aria-current="{{ candidate == view ? 'page' : 'false' }}"
|
||||
{% if compact %}aria-label="{{ label }}" title="{{ label }}"{% endif %}
|
||||
class="flex items-center justify-center text-xs transition-colors
|
||||
{{ compact ? 'w-7 h-7' : 'px-2.5 py-1' }}
|
||||
{{ candidate == view ? 'bg-accent text-white' : 'text-ink-muted hover:bg-hover hover:text-ink' }}"
|
||||
>
|
||||
{%- if compact -%}
|
||||
<i class="fa-solid {{ viewIcons[candidate.value] }}" aria-hidden="true"></i>
|
||||
{%- else -%}
|
||||
{{ label }}
|
||||
{%- endif -%}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_new') }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg shrink-0
|
||||
bg-accent text-white hover:opacity-90 transition-opacity"
|
||||
aria-label="{{ 'calendar.event.new'|trans }}"
|
||||
><i class="fa-solid fa-plus text-xs" aria-hidden="true"></i></button>
|
||||
|
||||
@@ -4,9 +4,14 @@ Agenda: a flat list, skipping empty days.
|
||||
This is the pane's default view, and the reason the pane is useful at all — a
|
||||
narrow column beside your mail should answer "what is coming up", not draw a
|
||||
grid nobody can read at that width.
|
||||
|
||||
Empty days are skipped rather than shown blank: the value here is density, and
|
||||
a fortnight of "nothing" between two events is noise. The grids are where an
|
||||
empty day still needs to occupy its place.
|
||||
#}
|
||||
|
||||
{% set dayKeys = days|keys %}
|
||||
{% set today = 'now'|date('Y-m-d') %}
|
||||
{% set anyEvents = false %}
|
||||
|
||||
<div class="divide-y divide-line">
|
||||
@@ -14,19 +19,28 @@ grid nobody can read at that width.
|
||||
{% set dayEvents = days[dayKey] %}
|
||||
{% if dayEvents|length > 0 %}
|
||||
{% set anyEvents = true %}
|
||||
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
|
||||
{% set isToday = dayKey == today %}
|
||||
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-sm {{ isToday ? 'font-semibold text-accent' : 'text-ink' }}">
|
||||
{{ isToday ? 'calendar.today'|trans : dayKey|date('D j M') }}
|
||||
</span>
|
||||
<div class="flex gap-3 px-3 py-2.5 {{ isToday ? 'bg-accent/5' : '' }}">
|
||||
{# A fixed-width date rail, so the titles line up down the list
|
||||
rather than starting wherever the weekday name happens to
|
||||
end. #}
|
||||
<div class="w-11 shrink-0 text-center">
|
||||
<div class="text-[10px] uppercase tracking-wide {{ isToday ? 'text-accent' : 'text-ink-faint' }}">
|
||||
{{ dayKey|date('D') }}
|
||||
</div>
|
||||
{% if isToday %}
|
||||
<span class="text-[11px] text-ink-faint">{{ dayKey|date('D j M') }}</span>
|
||||
<div class="mx-auto mt-0.5 w-6 h-6 flex items-center justify-center rounded-full
|
||||
bg-accent text-white text-sm font-semibold tabular-nums">
|
||||
{{ dayKey|date('j') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-0.5 text-sm text-ink tabular-nums">{{ dayKey|date('j') }}</div>
|
||||
{% endif %}
|
||||
<div class="text-[10px] text-ink-faint">{{ dayKey|date('M') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<div class="min-w-0 flex-1 space-y-0.5 self-center">
|
||||
{% for occurrence in dayEvents %}
|
||||
{% include 'calendar/_event_chip.html.twig' %}
|
||||
{% endfor %}
|
||||
@@ -37,7 +51,7 @@ grid nobody can read at that width.
|
||||
</div>
|
||||
|
||||
{% if not anyEvents %}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-center text-ink-faint">
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center text-ink-faint">
|
||||
<i class="fa-regular fa-calendar text-2xl" aria-hidden="true"></i>
|
||||
<p class="text-sm">{{ 'calendar.empty'|trans }}</p>
|
||||
<button type="button"
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
{#
|
||||
Day and week: one column per day, events listed inside it.
|
||||
Day and week: one column per day.
|
||||
|
||||
Deliberately not a time-grid with absolutely positioned blocks. That is the
|
||||
right shape for a wide screen and the wrong one for a 380px pane, and one
|
||||
readable layout that works in both is worth more right now than two that each
|
||||
work in one place. The time-grid belongs with drag-to-move, which is its own
|
||||
piece of work.
|
||||
readable layout that works in both is worth more than two that each work in one
|
||||
place. The time-grid belongs with drag-to-move, which is its own piece of work.
|
||||
|
||||
The columns fill the height rather than collapsing to their content — a week
|
||||
that stops a fifth of the way down the pane reads as a rendering fault, not as
|
||||
an empty week. Each column keeps an add affordance at the bottom, so a day that
|
||||
already has events is no harder to add to than an empty one.
|
||||
#}
|
||||
|
||||
{% set dayKeys = days|keys %}
|
||||
{% set today = 'now'|date('Y-m-d') %}
|
||||
|
||||
<div class="grid gap-px bg-line
|
||||
{{ dayKeys|length > 1 ? 'grid-cols-1 md:grid-cols-7' : 'grid-cols-1' }}">
|
||||
<div class="h-full min-h-0 grid gap-px bg-line
|
||||
{{ dayKeys|length > 1 ? 'grid-cols-2 sm:grid-cols-4 md:grid-cols-7' : 'grid-cols-1' }}">
|
||||
{% for dayKey in dayKeys %}
|
||||
{% set dayEvents = days[dayKey] %}
|
||||
{% set day = dayKey|date_modify('+0 days') %}
|
||||
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
|
||||
{% set isToday = dayKey == today %}
|
||||
{% set isWeekend = dayKey|date('N') >= 6 %}
|
||||
|
||||
<div class="bg-surface min-h-[7rem] flex flex-col">
|
||||
<div class="flex items-baseline gap-2 px-2 py-1.5 border-b border-line">
|
||||
<span class="text-[11px] uppercase tracking-wide text-ink-faint">
|
||||
<div class="flex flex-col min-h-0 min-w-0
|
||||
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}">
|
||||
|
||||
{# Sticky so the date stays put once a busy day starts scrolling. #}
|
||||
<div class="sticky top-0 z-10 flex items-baseline gap-1.5 px-2 py-1.5
|
||||
border-b border-line backdrop-blur-sm
|
||||
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}">
|
||||
<span class="text-[10px] uppercase tracking-wide {{ isToday ? 'text-accent' : 'text-ink-faint' }}">
|
||||
{{ dayKey|date('D') }}
|
||||
</span>
|
||||
<span class="text-sm {{ isToday ? 'font-semibold text-accent' : 'text-ink' }}">
|
||||
{{ dayKey|date('j') }}
|
||||
</span>
|
||||
{% if isToday %}
|
||||
<span class="w-5 h-5 -my-0.5 flex items-center justify-center rounded-full
|
||||
bg-accent text-white text-xs font-semibold tabular-nums">
|
||||
{{ dayKey|date('j') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-ink-muted tabular-nums">{{ dayKey|date('j') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-1 space-y-0.5">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||
{% for occurrence in dayEvents %}
|
||||
{% include 'calendar/_event_chip.html.twig' %}
|
||||
{% else %}
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
|
||||
class="block h-full min-h-[3rem] rounded-md text-xs text-transparent
|
||||
hover:text-ink-faint hover:bg-hover transition-colors
|
||||
flex items-center justify-center"
|
||||
>
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
{# Fills the rest of the column, so clicking anywhere below the
|
||||
last event adds one there — and stays reachable on a day that
|
||||
is already full. #}
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
|
||||
aria-label="{{ 'calendar.event.new_on'|trans({ '%date%': dayKey|date('D j M') }) }}"
|
||||
class="group/add w-full min-h-[2.5rem] {{ dayEvents|length > 0 ? '' : 'h-full' }}
|
||||
flex items-start justify-center pt-1 rounded-md
|
||||
text-ink-faint opacity-0 hover:opacity-100 focus:opacity-100
|
||||
hover:bg-hover transition-opacity
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
><i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
{#
|
||||
Month: a six-week grid.
|
||||
Month: a six-week grid that fills its container.
|
||||
|
||||
Always six weeks, never five-or-six. The range enum asks for 42 days from the
|
||||
Monday on or before the first, so the grid does not change height between
|
||||
months — a calendar that reflows as you page through it is disorienting, and it
|
||||
means the surrounding layout never has to reserve for the taller case.
|
||||
|
||||
`grid-rows-6` with `h-full` is what makes the six weeks share the height evenly
|
||||
instead of each row shrinking to its busiest cell, which left a ragged block of
|
||||
empty space under the grid.
|
||||
#}
|
||||
|
||||
{% set dayKeys = days|keys %}
|
||||
{% set anchorMonth = anchor|date('n') %}
|
||||
{% set today = 'now'|date('Y-m-d') %}
|
||||
|
||||
<div class="grid grid-cols-7 text-[11px] uppercase tracking-wide text-ink-faint border-b border-line">
|
||||
{% for dayKey in dayKeys|slice(0, 7) %}
|
||||
<div class="px-2 py-1.5">{{ dayKey|date('D') }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-px bg-line">
|
||||
{% for dayKey in dayKeys %}
|
||||
{% set dayEvents = days[dayKey] %}
|
||||
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
|
||||
{% set isOtherMonth = dayKey|date('n') != anchorMonth %}
|
||||
|
||||
<div class="bg-surface min-h-[5.5rem] p-1 flex flex-col gap-0.5
|
||||
{{ isOtherMonth ? 'opacity-50' : '' }}">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-xs {{ isToday ? 'font-semibold text-accent' : 'text-ink-muted' }}">
|
||||
{{ dayKey|date('j') }}
|
||||
</span>
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100 text-[10px] text-ink-faint"
|
||||
aria-label="{{ 'calendar.event.new'|trans }}"
|
||||
><i class="fa-solid fa-plus" aria-hidden="true"></i></button>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="grid grid-cols-7 shrink-0 border-b border-line">
|
||||
{% for dayKey in dayKeys|slice(0, 7) %}
|
||||
<div class="px-2 py-1.5 text-[10px] uppercase tracking-wide text-ink-faint">
|
||||
{{ dayKey|date('D') }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Three, then a count. A month cell that grows to fit everything
|
||||
makes every other row taller for one busy day. #}
|
||||
{% for occurrence in dayEvents|slice(0, 3) %}
|
||||
{% include 'calendar/_event_chip.html.twig' %}
|
||||
{% endfor %}
|
||||
<div class="flex-1 min-h-0 grid grid-cols-7 grid-rows-6 gap-px bg-line">
|
||||
{% for dayKey in dayKeys %}
|
||||
{% set dayEvents = days[dayKey] %}
|
||||
{% set isToday = dayKey == today %}
|
||||
{% set isWeekend = dayKey|date('N') >= 6 %}
|
||||
{% set isOtherMonth = dayKey|date('n') != anchorMonth %}
|
||||
|
||||
{% if dayEvents|length > 3 %}
|
||||
<a href="{{ path('app_calendar_view', { view: 'day', date: dayKey }) }}"
|
||||
class="px-1.5 text-[11px] text-ink-faint hover:text-ink">
|
||||
{{ 'calendar.more_count'|trans({ '%count%': dayEvents|length - 3 }) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{# The spill-in days are dimmed as a whole cell, not just by their
|
||||
date number: a neighbouring month's events rendered at full
|
||||
strength read as part of this month. #}
|
||||
<div class="group/cell min-h-0 min-w-0 flex flex-col p-1 gap-0.5 overflow-hidden
|
||||
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}
|
||||
{{ isOtherMonth ? 'opacity-55' : '' }}">
|
||||
|
||||
<div class="flex items-center justify-between shrink-0 px-0.5">
|
||||
{% if isToday %}
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-full
|
||||
bg-accent text-white text-xs font-semibold tabular-nums">
|
||||
{{ dayKey|date('j') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs tabular-nums {{ isOtherMonth ? 'text-ink-faint' : 'text-ink-muted' }}">
|
||||
{{ dayKey|date('j') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
data-action="click->ui--modal#open"
|
||||
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
|
||||
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
|
||||
aria-label="{{ 'calendar.event.new_on'|trans({ '%date%': dayKey|date('D j M') }) }}"
|
||||
class="w-5 h-5 flex items-center justify-center rounded-md text-[10px] text-ink-faint
|
||||
opacity-0 group-hover/cell:opacity-100 focus:opacity-100
|
||||
hover:bg-hover transition-opacity
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
><i class="fa-solid fa-plus" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
|
||||
{# Three, then a count. A month cell that grows to fit everything
|
||||
makes every other row taller for one busy day. #}
|
||||
<div class="min-h-0 flex-1 space-y-0.5 overflow-hidden">
|
||||
{% for occurrence in dayEvents|slice(0, 3) %}
|
||||
{% include 'calendar/_event_chip.html.twig' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if dayEvents|length > 3 %}
|
||||
<a href="{{ path('app_calendar_view', { view: 'day', date: dayKey }) }}{{ compact|default(false) ? '?pane=1' : '' }}"
|
||||
{% if frame|default(null) %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
class="shrink-0 px-1.5 text-[11px] text-ink-faint hover:text-ink hover:underline">
|
||||
{{ 'calendar.more_count'|trans({ '%count%': dayEvents|length - 3 }) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
+109
-4
@@ -44,7 +44,7 @@ test.describe("calendar", () => {
|
||||
test("creates an event and shows it in the week", async ({ page }) => {
|
||||
await page.goto("/calendar/week");
|
||||
|
||||
await page.getByRole("button", { name: "New event" }).click();
|
||||
await page.getByRole("button", { name: "New event", exact: true }).click();
|
||||
|
||||
const modal = page.locator("#modal-backdrop");
|
||||
await expect(modal).toBeVisible();
|
||||
@@ -67,7 +67,7 @@ test.describe("calendar", () => {
|
||||
test("a repeating event appears on more than one day", async ({ page }) => {
|
||||
await page.goto("/calendar/week");
|
||||
|
||||
await page.getByRole("button", { name: "New event" }).click();
|
||||
await page.getByRole("button", { name: "New event", exact: true }).click();
|
||||
|
||||
const modal = page.locator("#modal-backdrop");
|
||||
await modal.getByLabel("Title").fill(TITLE);
|
||||
@@ -147,13 +147,24 @@ test.describe("calendar pane", () => {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 10 });
|
||||
|
||||
// The write happens on release and is deliberately not awaited by the
|
||||
// UI, so wait for it here rather than racing the reload against it.
|
||||
const persisted = page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes("/calendar/pane-state") &&
|
||||
"POST" === response.request().method() &&
|
||||
(await response.json()).width > Math.round(before.width),
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
await persisted;
|
||||
|
||||
const dragged = (await pane.boundingBox())!.width;
|
||||
expect(dragged).toBeGreaterThan(before.width);
|
||||
|
||||
// The width is written on release, and rendered inline on the next
|
||||
// paint — so the reload is the assertion, not the drag.
|
||||
// The width is rendered inline on the next paint, so the reload is the
|
||||
// assertion, not the drag.
|
||||
await page.reload();
|
||||
await expect(pane).toBeVisible();
|
||||
expect((await pane.boundingBox())!.width).toBeCloseTo(dragged, 0);
|
||||
@@ -179,6 +190,100 @@ test.describe("calendar pane", () => {
|
||||
expect((await backdrop.boundingBox())!.width).toBeGreaterThan(paneWidth * 2);
|
||||
});
|
||||
|
||||
test("reaches the month view from inside the pane", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await ensurePaneClosed(page);
|
||||
await page.getByRole("link", { name: "Calendar" }).click();
|
||||
|
||||
const paneFrame = page.locator("turbo-frame#calendar-pane-frame");
|
||||
await expect(paneFrame).toBeVisible();
|
||||
|
||||
// Icons, not words, at this width — but all four views are reachable,
|
||||
// and switching one stays inside the frame rather than navigating the
|
||||
// whole page away from the mail.
|
||||
await paneFrame.getByRole("link", { name: "Month" }).click();
|
||||
|
||||
await expect(paneFrame.getByRole("link", { name: "Month" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
await expect(page.locator("#message-list")).toBeVisible();
|
||||
});
|
||||
|
||||
test("will not squeeze the mail pane below its minimum", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await ensurePaneClosed(page);
|
||||
await page.getByRole("link", { name: "Calendar" }).click();
|
||||
|
||||
const mainPane = page.locator('[data-ui--split-target="main"]');
|
||||
const handle = page.locator('[data-ui--split-target="handle"]');
|
||||
await expect(handle).toBeVisible();
|
||||
|
||||
// Drag far past anywhere sensible: the clamp is what is under test, not
|
||||
// the arithmetic of a modest drag.
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(20, box.y + box.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
expect((await mainPane.boundingBox())!.width).toBeGreaterThanOrEqual(419);
|
||||
});
|
||||
|
||||
test("centres its grip in the gap between the panes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await ensurePaneClosed(page);
|
||||
await page.getByRole("link", { name: "Calendar" }).click();
|
||||
|
||||
const mainPane = page.locator('[data-ui--split-target="main"]');
|
||||
const pane = page.locator('[data-ui--split-target="pane"]');
|
||||
const grip = page.locator('[data-ui--split-target="handle"] span');
|
||||
|
||||
const mainBox = (await mainPane.boundingBox())!;
|
||||
const paneBox = (await pane.boundingBox())!;
|
||||
const gripBox = (await grip.boundingBox())!;
|
||||
|
||||
// The row's own gap falls to the left of the wrapper, so a handle only
|
||||
// one gutter wide sits off to the calendar's side of the gap.
|
||||
const gapCentre = (mainBox.x + mainBox.width + paneBox.x) / 2;
|
||||
expect(gripBox.x + gripBox.width / 2).toBeCloseTo(gapCentre, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The rows below the list respond to the LIST's width, not the window's.
|
||||
* Before the container query they switched on the viewport, so a wide
|
||||
* window with a wide calendar pane left a list narrower than a phone still
|
||||
* rendering one-line rows, truncated to nothing.
|
||||
*/
|
||||
test("stacks the mail rows once the pane has taken enough width", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await ensurePaneClosed(page);
|
||||
|
||||
const inlineDate = page.locator('[data-row-date="inline"]').first();
|
||||
const stackedDate = page.locator('[data-row-date="stacked"]').first();
|
||||
|
||||
// On display rather than visibility: the inline date column is laid out
|
||||
// as a zero-height positioning context for an absolutely placed span,
|
||||
// so Playwright calls it hidden either way. Which layout the container
|
||||
// query picked is the actual subject here.
|
||||
await expect(inlineDate).toHaveCSS("display", "flex");
|
||||
await expect(stackedDate).toHaveCSS("display", "none");
|
||||
|
||||
await page.getByRole("link", { name: "Calendar" }).click();
|
||||
await expect(page.locator("turbo-frame#calendar-pane-frame")).toBeVisible();
|
||||
|
||||
// Drag the pane out to its limit, which is where the list is tightest.
|
||||
const handle = page.locator('[data-ui--split-target="handle"]');
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(20, box.y + box.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(stackedDate).toHaveCSS("display", "block");
|
||||
await expect(inlineDate).toHaveCSS("display", "none");
|
||||
});
|
||||
|
||||
test("is absent on a phone, where the link opens the page instead", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
@@ -317,6 +317,7 @@ calendar:
|
||||
Änderungen nicht mehr.
|
||||
location: Ort
|
||||
new: Neuer Termin
|
||||
new_on: Neuer Termin am %date%
|
||||
repeat: Wiederholung
|
||||
save: Speichern
|
||||
starts: Beginn
|
||||
|
||||
@@ -314,6 +314,7 @@ calendar:
|
||||
about the same booking from overwriting your changes.
|
||||
location: Location
|
||||
new: New event
|
||||
new_on: New event on %date%
|
||||
repeat: Repeat
|
||||
save: Save
|
||||
starts: Starts
|
||||
|
||||
Reference in new issue
Block a user