Writing on a phone: the composer rises as a sheet

On a phone the composer opened at the top of the feed whether or not the feed was scrolled there, and a reply, quote
or edit opened inside its card's 228px column. New PhoneSheet shows them as a sheet from the bottom of the screen below
md, over a dimmed page, with a heading and a close button (a tap outside or Escape closes it too); from md they stay in
place as before. On a phone a new post starts from a round button under the thumb (the feed keeps room for it at the
bottom), and the tabs' Post button shows from md. Opening the composer scrolls the feed to its top, and the composer's
words take the focus (DTextarea.Element). Picking a suggested mention or hashtag keeps the focus, and the phone's
keyboard, in the words (DButton.KeepFocus, and the words are focused again). The character count sits under the words
instead of over their last line, and the visibility menu names each choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 23:15:54 +02:00
1 parent b3bb2a28c3
commit 071bcce4b0
9 files changed
+108 -30

No files matched your search

+26 -10
View File
@@ -17,18 +17,19 @@
<Prefix><i class="ion-md-warning text-warning"></i></Prefix> <Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput> </DInput>
} }
<div class="relative"> <div class="flex flex-col gap-1">
<DTextarea TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true" <DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off" class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@Placeholder" rows="3" data-compose /> placeholder="@Placeholder" rows="3" data-compose />
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span> @* under the words, not over their last line *@
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
</div> </div>
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0) @if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
{ {
<div class="flex flex-wrap gap-2" data-suggestions> <div class="flex flex-wrap gap-2" data-suggestions>
@foreach (var account in suggestedAccounts) @foreach (var account in suggestedAccounts)
{ {
<DButton Size="DSize.Xs" class="max-w-full" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)"> <DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
@* alt="": the handle is next to it. *@ @* alt="": the handle is next to it. *@
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" /> <img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="truncate">@("@" + account.Acct)</span> <span class="truncate">@("@" + account.Acct)</span>
@@ -36,7 +37,7 @@
} }
@foreach (var tag in suggestedTags) @foreach (var tag in suggestedTags)
{ {
<DButton Size="DSize.Xs" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton> <DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
} }
</div> </div>
} }
@@ -131,16 +132,18 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null && Editing is null) @if (!form.IsLocated && GroupId is null && Editing is null)
{ {
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" <DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"> ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
<Trigger> <Trigger>
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i> <i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
</Trigger> </Trigger>
<ChildContent> <ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>()) @foreach (var messageType in Enum.GetValues<MessageType>())
{ {
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
<i class="@messageType.GetMessageTypeIcon() text-base"></i> <i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
</DButton> </DButton>
} }
</ChildContent> </ChildContent>
@@ -293,6 +296,7 @@
if (!typed.Success) if (!typed.Success)
return; return;
form.Content = form.Content[..typed.Groups[1].Index] + written + " "; form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
focusPending = true;
} }
string Placeholder => Editing is not null ? Localizer["Edit your post"] string Placeholder => Editing is not null ? Localizer["Edit your post"]
@@ -340,6 +344,18 @@
_ => MessageType.Public _ => MessageType.Public
}; };
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
bool focusPending = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await textarea.Element.FocusAsync();
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
form.GroupId = GroupId; form.GroupId = GroupId;
+7 -9
View File
@@ -406,18 +406,16 @@
</div> </div>
</footer> </footer>
@if (isReplying) @* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
{ <PhoneSheet IsOpen="isReplying" OnClose="() => isReplying = false" Heading="@Localizer["Reply to {0}", "@" + status.Account?.Acct]">
<Composer InReplyTo="status" OnPublished="Replied" /> <Composer InReplyTo="status" OnPublished="Replied" />
} </PhoneSheet>
@if (isQuoting) <PhoneSheet IsOpen="isQuoting" OnClose="() => isQuoting = false" Heading="@Localizer["Quote"]">
{
<Composer Quoting="status" OnPublished="Quoted" /> <Composer Quoting="status" OnPublished="Quoted" />
} </PhoneSheet>
@if (isEditing) <PhoneSheet IsOpen="isEditing" OnClose="() => isEditing = false" Heading="@Localizer["Edit"]">
{
<Composer Editing="status" OnPublished="Edited" /> <Composer Editing="status" OnPublished="Edited" />
} </PhoneSheet>
@if (isReporting && status.Account is { } reported) @if (isReporting && status.Account is { } reported)
{ {
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" /> <ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
+4 -1
View File
@@ -3,7 +3,8 @@
@* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@ @* daisyUI Button: https://daisyui.com/components/button/ . Renders an <a> when Href is set. *@
@if (Href is null) @if (Href is null)
{ {
<button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"> <button @attributes="AdditionalAttributes" type="@Type" class="@CssClass" disabled="@Disabled" @onclick="OnClick"
@onmousedown:preventDefault="KeepFocus">
@ChildContent @ChildContent
</button> </button>
} }
@@ -27,6 +28,8 @@ else
[Parameter] public string Type { get; set; } = "button"; [Parameter] public string Type { get; set; } = "button";
[Parameter] public string Href { get; set; } [Parameter] public string Href { get; set; }
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; } [Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>Pressing it doesn't take the focus from where it is (a chip that writes into a focused textarea).</summary>
[Parameter] public bool KeepFocus { get; set; }
string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass, string CssClass => Cx("btn", ColorClass, VariantClass, SizeClass, ShapeClass,
Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class); Active ? "btn-active" : null, Disabled && Href is not null ? "btn-disabled" : null, Class);
+4 -1
View File
@@ -3,10 +3,13 @@
@* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound @* daisyUI Textarea: https://daisyui.com/components/textarea/ . Blazor sets the textarea's value property, so the bound
value is the content; rows, placeholder and the like pass through. *@ value is the content; rows, placeholder and the like pass through. *@
<textarea @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString" <textarea @ref="Element" @attributes="InputAttributes" class="@CssClassFull" name="@NameAttribute" value="@CurrentValueAsString"
@oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea> @oninput="OnInput" @onchange="OnChange" aria-invalid="@(IsInvalid ? "true" : null)"></textarea>
@code { @code {
/// <summary>The textarea itself, for focusing it.</summary>
public ElementReference Element { get; private set; }
[Parameter] public DColor Color { get; set; } [Parameter] public DColor Color { get; set; }
[Parameter] public DSize Size { get; set; } [Parameter] public DSize Size { get; set; }
[Parameter] public bool Ghost { get; set; } [Parameter] public bool Ghost { get; set; }
+1 -1
View File
@@ -33,7 +33,7 @@
<DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]"> <DButton Size="DSize.Sm" class="lg:hidden" Active="Filter.ShowGlobe" OnClick="ToggleGlobe" title="@Localizer["Globe"]">
<i class="ion-md-planet text-base"></i> <i class="ion-md-planet text-base"></i>
</DButton> </DButton>
<DButton Size="DSize.Sm" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]"> <DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="IsComposing" OnClick="() => OnCompose.InvokeAsync()" title="@Localizer["Post"]">
<i class="ion-md-create text-base"></i> <i class="ion-md-create text-base"></i>
<span class="hidden sm:inline">@Localizer["Post"]</span> <span class="hidden sm:inline">@Localizer["Post"]</span>
</DButton> </DButton>
+37
View File
@@ -0,0 +1,37 @@
@inherits LocalizableComponentBase
@* What opens in place on a desktop (the composer at the top of the feed, a reply under its post) and as a sheet from the
bottom of a phone's screen (below md), over a dimmed page, with its heading and a close button; a tap outside it or
Escape closes it too. Class is the inline look from md (padding, a raised panel). *@
@if (IsOpen)
{
<div class="max-md:fixed max-md:inset-0 max-md:z-[60] max-md:flex max-md:flex-col max-md:justify-end" @onkeydown="OnKeyDown">
<div class="md:hidden absolute inset-0 bg-black/40" aria-hidden="true" @onclick="Close"></div>
<div class="@($"relative max-md:max-h-[85dvh] max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-t-box max-md:bg-base-100 max-md:p-4 max-md:pb-[max(1rem,env(safe-area-inset-bottom))] {Class}")"
data-sheet>
<div class="md:hidden flex items-center gap-3 mb-3">
<b class="flex-1 min-w-0 truncate">@Heading</b>
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@Localizer["Close"]" title="@Localizer["Close"]"
@onclick="Close" data-sheet-close>
<i class="ion-md-close" aria-hidden="true"></i>
</button>
</div>
@ChildContent
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>The sheet's heading on a phone.</summary>
[Parameter] public string Heading { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public string Class { get; set; }
protected override void OnInitialized() => IsLoading = false;
Task Close() => OnClose.InvokeAsync();
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask;
}
+23 -8
View File
@@ -18,19 +18,26 @@
</aside> </aside>
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full"> <section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing" @if (!isComposing)
{
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
<DButton Shape="DButtonShape.Circle" Size="DSize.Lg" Color="DColor.Primary" OnClick="ToggleCompose"
class="md:hidden fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-30" aria-label="@Localizer["New post"]"
title="@Localizer["New post"]" data-compose-open>
<i class="ion-md-create text-xl" aria-hidden="true"></i>
</DButton>
}
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose"
OnChanged="ChangeFeed" /> OnChanged="ChangeFeed" />
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl"> <div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4"> @* (on a phone the bottom stays clear of the New post button) *@
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 max-md:pb-24 md:p-4">
<div class="flex flex-col gap-4" data-feed-count="@posts.Count"> <div class="flex flex-col gap-4" data-feed-count="@posts.Count">
@if (isComposing) <PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
{ <Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs"> </PhoneSheet>
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
</div>
}
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0) @if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
{ {
@@ -461,6 +468,14 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
// a composer that opens shows at the top of the feed, where it is, however far down the feed was read
async Task ToggleCompose()
{
isComposing = !isComposing;
if (isComposing && feedModule is not null)
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
}
async Task ShowPending() async Task ShowPending()
{ {
var shown = pending; var shown = pending;
+3
View File
@@ -1820,4 +1820,7 @@
<data name="Menu" xml:space="preserve"> <data name="Menu" xml:space="preserve">
<value>Menu</value> <value>Menu</value>
</data> </data>
<data name="New post" xml:space="preserve">
<value>Nuovo post</value>
</data>
</root> </root>
+3
View File
@@ -1820,4 +1820,7 @@
<data name="Menu" xml:space="preserve"> <data name="Menu" xml:space="preserve">
<value>Menu</value> <value>Menu</value>
</data> </data>
<data name="New post" xml:space="preserve">
<value>New post</value>
</data>
</root> </root>