Dropdowns open as sheets on phones, come into view, and take no room closed
A closed dropdown's content stayed displayed whenever it carried a display class (flex, grid), only invisible, so it still took room: the React picker past a card's right edge made the whole feed scroll sideways on a phone (294px at 360px). A closed one is now not displayed at all. DDropdown moves the focus into its content on opening, which also scrolls a menu cut off by the feed into view, and gives it back to its trigger after Escape or an outside tap. New SheetOnNarrow makes the content a sheet from the bottom of the screen below md, over a dimmed page, at most 80dvh tall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
f1cb90ac03
commit
63f2517f5b
2 files changed
+49
-16
No files matched your search
@@ -3,17 +3,21 @@
|
|||||||
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
@* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen):
|
||||||
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed.
|
||||||
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
While open, a transparent full-screen layer under the content closes it on an outside click; Escape closes it
|
||||||
too. With Hover the CSS hover rule opens it instead. *@
|
too. With Hover the CSS hover rule opens it instead. Opening moves the focus into the content (Blazor's FocusAsync),
|
||||||
|
which also scrolls a menu cut off by its scroller into view; Escape or an outside click gives it back to the trigger.
|
||||||
|
SheetOnNarrow makes the content a sheet rising from the bottom of a phone's screen (below md), over a dimmed page. *@
|
||||||
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
<div @attributes="AdditionalAttributes" class="@CssClass" @onkeydown="OnKeyDown">
|
||||||
<div role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
<div @ref="trigger" role="button" tabindex="0" class="@TriggerClass" aria-haspopup="true" aria-expanded="@(IsOpen ? "true" : "false")"
|
||||||
@onclick="Toggle">
|
@onclick="Toggle">
|
||||||
@Trigger
|
@Trigger
|
||||||
</div>
|
</div>
|
||||||
@if (IsOpen)
|
@if (IsOpen)
|
||||||
{
|
{
|
||||||
<div class="fixed inset-0 z-[998] cursor-default" aria-hidden="true" @onclick="Close"></div>
|
<div class="@(SheetOnNarrow ? "fixed inset-0 z-[998] cursor-default max-md:bg-black/40" : "fixed inset-0 z-[998] cursor-default")"
|
||||||
|
aria-hidden="true" @onclick="Close"></div>
|
||||||
}
|
}
|
||||||
<div tabindex="-1" class="@Cx("dropdown-content z-[999]", ContentClass)" @onclick="OnContentClick">
|
<div @ref="content" tabindex="-1" class="@Cx("dropdown-content z-[999]", SheetOnNarrow ? Sheet : null, ContentClass)"
|
||||||
|
data-sheet="@(SheetOnNarrow ? "" : null)" @onclick="OnContentClick">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +35,17 @@
|
|||||||
[Parameter] public bool CloseOnContentClick { get; set; }
|
[Parameter] public bool CloseOnContentClick { get; set; }
|
||||||
[Parameter] public string TriggerClass { get; set; }
|
[Parameter] public string TriggerClass { get; set; }
|
||||||
[Parameter] public string ContentClass { get; set; }
|
[Parameter] public string ContentClass { get; set; }
|
||||||
|
/// <summary>Below md, the content is a sheet at the bottom of the screen instead of hanging from the trigger.</summary>
|
||||||
|
[Parameter] public bool SheetOnNarrow { get; set; }
|
||||||
|
|
||||||
|
const string Sheet = "max-md:fixed max-md:inset-x-0 max-md:bottom-0 max-md:top-auto max-md:m-0 max-md:w-full max-md:max-h-[80dvh] "
|
||||||
|
+ "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 "
|
||||||
|
+ "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]";
|
||||||
|
|
||||||
|
ElementReference trigger;
|
||||||
|
ElementReference content;
|
||||||
|
bool focusContent;
|
||||||
|
bool focusTrigger;
|
||||||
|
|
||||||
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
string CssClass => Cx("dropdown", PlacementClass, AlignClass,
|
||||||
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
IsOpen ? "dropdown-open" : Hover ? "dropdown-hover" : "dropdown-close", Class);
|
||||||
@@ -50,16 +65,33 @@
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
Task Toggle() => SetOpen(!IsOpen);
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
|
||||||
Task Close() => SetOpen(false);
|
|
||||||
|
|
||||||
Task OnContentClick() => CloseOnContentClick ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false) : Task.CompletedTask;
|
|
||||||
|
|
||||||
async Task SetOpen(bool isOpen)
|
|
||||||
{
|
{
|
||||||
|
if (focusContent)
|
||||||
|
{
|
||||||
|
focusContent = false;
|
||||||
|
await content.FocusAsync();
|
||||||
|
}
|
||||||
|
if (focusTrigger)
|
||||||
|
{
|
||||||
|
focusTrigger = false;
|
||||||
|
await trigger.FocusAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Toggle() => SetOpen(!IsOpen, IsOpen);
|
||||||
|
|
||||||
|
Task Close() => SetOpen(false, true);
|
||||||
|
|
||||||
|
// (a menu item may open a dialog, which takes the focus itself: the trigger doesn't take it back)
|
||||||
|
Task OnContentClick() => CloseOnContentClick ? SetOpen(false, false) : Task.CompletedTask;
|
||||||
|
|
||||||
|
Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" && IsOpen ? SetOpen(false, true) : Task.CompletedTask;
|
||||||
|
|
||||||
|
async Task SetOpen(bool isOpen, bool giveFocusBack)
|
||||||
|
{
|
||||||
|
focusContent = isOpen && !IsOpen;
|
||||||
|
focusTrigger = !isOpen && IsOpen && giveFocusBack;
|
||||||
IsOpen = isOpen;
|
IsOpen = isOpen;
|
||||||
await IsOpenChanged.InvokeAsync(isOpen);
|
await IsOpenChanged.InvokeAsync(isOpen);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -278,10 +278,11 @@
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
box-shadow: -3px -3px 6px var(--neo-light), 3px 3px 6px var(--neo-dark);
|
||||||
|
|
||||||
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stays
|
/* A display utility on the content (flex, grid) beats daisyUI's display:none on a closed dropdown, which then stayed
|
||||||
invisible (opacity 0) but still in the way of the pointer, over whatever it covers: hide it from the pointer too. */
|
invisible (opacity 0) but in the way of the pointer, and still took room: a closed menu past a card's edge made
|
||||||
|
the feed scroll sideways on a phone. A closed one is not displayed at all. */
|
||||||
:where(.dropdown.dropdown-close, .dropdown:not(.dropdown-open, .dropdown-hover:hover, :focus-within)) & {
|
:where(.dropdown.dropdown-close, .dropdown:not(.dropdown-open, .dropdown-hover:hover, :focus-within)) & {
|
||||||
visibility: hidden;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user