Feed tabs say what they are

Below xl every feed tab but the selected one was a lone icon with no name, for a screen reader or a finger. DTab takes
an AccessibleName, which DTabs renders as the tab's label and tooltip, and FeedTabs names each tab; on a phone every
tab shows its name too. The strip is marked as one that scrolls sideways (data-scroll-x), says it holds the feeds, and
on a phone fades at the edge where more tabs wait (edge-fade-x, a scroll-driven animation where the browser has one);
the selected tab is brought into view when it changes (window.revealActive). Explore's tabs get the same.

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:31:07 +02:00
1 parent 1c9773bab0
commit dd38002682
9 files changed
+97 -6

No files matched your search

+2
View File
@@ -9,6 +9,8 @@
[Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
[Parameter] public string Class { get; set; } [Parameter] public string Class { get; set; }
/// <summary>The tab's name for a screen reader and as its tooltip, when its Header shows only an icon.</summary>
[Parameter] public string AccessibleName { get; set; }
protected override void OnInitialized() protected override void OnInitialized()
{ {
+1
View File
@@ -12,6 +12,7 @@
var index = i; var index = i;
var tab = tabs[i]; var tab = tabs[i];
<button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled" <button type="button" role="tab" aria-selected="@(index == Active ? "true" : "false")" disabled="@tab.Disabled"
aria-label="@tab.AccessibleName" title="@tab.AccessibleName"
class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)" class="@Cx("tab", index == Active ? "tab-active" : null, tab.Disabled ? "tab-disabled" : null, tab.Class)"
@onclick="() => Select(index)"> @onclick="() => Select(index)">
@tab.Header @tab.Header
+18 -4
View File
@@ -6,14 +6,17 @@
read; the buttons on the right open the composer and, on narrow screens, the globe. *@ read; the buttons on the right open the composer and, on narrow screens, the globe. *@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="flex-1 min-w-0 overflow-x-auto"> @* a sideways strip on a phone, every tab named, the selected one brought into view and the edges faded where more
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"> tabs wait; from md only the selected tab shows its name until xl *@
<div @ref="strip" class="flex-1 min-w-0 overflow-x-auto max-md:edge-fade-x" data-scroll-x>
<DTabs Active="ActiveIndex" ActiveChanged="Select" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
aria-label="@Localizer["Feeds"]">
@foreach (var type in Types) @foreach (var type in Types)
{ {
<DTab Class="gap-1 whitespace-nowrap"> <DTab Class="gap-1 whitespace-nowrap" AccessibleName="@TabName(type)">
<Header> <Header>
<i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i> <i class="@type.GetTimelineTypeIcon()" aria-hidden="true"></i>
<span class="@(type == Filter.TimelineType ? null : "hidden xl:inline")">@TabName(type)</span> <span class="@(type == Filter.TimelineType ? null : "hidden max-md:inline xl:inline")">@TabName(type)</span>
@if (type is TimelineType.Notifications && UnreadNotifications > 0) @if (type is TimelineType.Notifications && UnreadNotifications > 0)
{ {
<DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge> <DBadge Size="DSize.Xs" Color="DColor.Primary" title="@Localizer["{0} unread", UnreadNotifications]" data-unread>@UnreadNotifications</DBadge>
@@ -127,6 +130,17 @@
int ActiveIndex => Types.IndexOf(Filter.TimelineType); int ActiveIndex => Types.IndexOf(Filter.TimelineType);
ElementReference strip;
int revealed = -1;
protected override void OnAfterRender(bool firstRender)
{
if (ActiveIndex == revealed)
return;
revealed = ActiveIndex;
CascadingState.Status.RevealActive(strip);
}
ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId); ViewGroup SelectedGroup => Groups.FirstOrDefault(g => g.Id == Filter.GroupId);
[Inject] DiscoveryService DiscoveryService { get; set; } [Inject] DiscoveryService DiscoveryService { get; set; }
+14 -2
View File
@@ -14,8 +14,9 @@
<h1 class="font-bold truncate">@Localizer["Explore"]</h1> <h1 class="font-bold truncate">@Localizer["Explore"]</h1>
</div> </div>
<div class="overflow-x-auto flex-none"> <div @ref="strip" class="overflow-x-auto flex-none max-md:edge-fade-x" data-scroll-x>
<DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"> <DTabs Active="(int)tab" ActiveChanged="SelectTab" Variant="DTabsVariant.Box" Size="DSize.Sm" ContentClass="hidden" class="flex-nowrap w-max"
aria-label="@Localizer["Explore"]">
<DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab> <DTab><Header><i class="ion-md-pricetag"></i> @Localizer["Hashtags"]</Header></DTab>
<DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab> <DTab><Header><i class="ion-md-flame"></i> @Localizer["Popular posts"]</Header></DTab>
<DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab> <DTab><Header><i class="ion-md-link"></i> @Localizer["Shared links"]</Header></DTab>
@@ -139,6 +140,17 @@
</section> </section>
@code { @code {
ElementReference strip;
int revealed = -1;
protected override void OnAfterRender(bool firstRender)
{
if ((int)tab == revealed)
return;
revealed = (int)tab;
CascadingState.Status.RevealActive(strip);
}
enum ExploreTab enum ExploreTab
{ {
Hashtags, Hashtags,
+3
View File
@@ -1853,4 +1853,7 @@
<data name="Every persona of this login is signed out on this device." xml:space="preserve"> <data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Tutte le persone di questo accesso escono da questo dispositivo.</value> <value>Tutte le persone di questo accesso escono da questo dispositivo.</value>
</data> </data>
<data name="Feeds" xml:space="preserve">
<value>Feed</value>
</data>
</root> </root>
+3
View File
@@ -1853,4 +1853,7 @@
<data name="Every persona of this login is signed out on this device." xml:space="preserve"> <data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Every persona of this login is signed out on this device.</value> <value>Every persona of this login is signed out on this device.</value>
</data> </data>
<data name="Feeds" xml:space="preserve">
<value>Feeds</value>
</data>
</root> </root>
+4
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Components;
using decePubClient.Models; using decePubClient.Models;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -41,6 +42,9 @@ public class AppStatusService(IJSRuntime jsRuntime)
public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia"); public bool IsMobileMedia() => JsSyncRuntime.Invoke<bool>("isMobileMedia");
// a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff"); public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) => public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
+43
View File
@@ -112,3 +112,46 @@
} }
} }
} }
/* A sideways strip fades at the edge where more of it waits: the right edge at first, both while scrolled, the left
at the end (a scroll-driven animation where the browser has one; without, the right edge only). */
@property --fade-start {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --fade-end {
syntax: "<length>";
inherits: false;
initial-value: 1.5rem;
}
@keyframes edge-fade-x {
from {
--fade-start: 0px;
--fade-end: 1.5rem;
}
8% {
--fade-start: 1.5rem;
}
92% {
--fade-end: 1.5rem;
}
to {
--fade-start: 1.5rem;
--fade-end: 0px;
}
}
@utility edge-fade-x {
mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
@supports (animation-timeline: scroll()) {
animation: edge-fade-x linear both;
animation-timeline: scroll(self inline);
}
}
+9
View File
@@ -123,6 +123,15 @@ window.clearAppBadge = () => {
window.logFromJs(e.message, "clearAppBadge") window.logFromJs(e.message, "clearAppBadge")
} }
} }
// the selected tab of a sideways-scrolling strip, brought into its middle
window.revealActive = strip => {
try {
strip?.querySelector("[aria-selected=true]")?.scrollIntoView({ inline: "center", block: "nearest" })
} catch (e) {
console.error(e)
window.logFromJs(e.message, "revealActive")
}
}
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant) // a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
window.isMobileMedia = () => { window.isMobileMedia = () => {
try { try {