From cf9c30d776f5d51b41590990796785164e932e8c Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 06:09:17 +0330 Subject: [PATCH 1/4] apply BitStick improvements #13108 --- .../Utilities/Sticky/BitSticky.razor | 8 +- .../Utilities/Sticky/BitSticky.razor.cs | 296 +++++++++++++- .../Utilities/Sticky/BitSticky.scss | 9 +- .../Utilities/Sticky/BitStickyPosition.cs | 30 ++ .../JsInterop/StickiesJsRuntimeExtensions.cs | 14 + src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts | 219 ++++++++++ .../Utilities/Sticky/BitStickyDemo.razor | 273 ++++++++++++- .../Utilities/Sticky/BitStickyDemo.razor.cs | 383 +++++++++++++++++- .../Utilities/Sticky/BitStickyDemo.razor.scss | 24 ++ .../Utilities/Sticky/BitStickyTests.cs | 197 ++++++++- 10 files changed, 1417 insertions(+), 36 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs create mode 100644 src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor index 3b653759fa7..48882079a82 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor @@ -1,12 +1,12 @@ -@namespace Bit.BlazorUI +@namespace Bit.BlazorUI @inherits BitComponentBase
+ dir="@Dir?.ToString().ToLowerInvariant()"> @ChildContent -
\ No newline at end of file + diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs index 7032a1a1386..254d6688f65 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs @@ -1,13 +1,53 @@ -namespace Bit.BlazorUI; +using System.Globalization; + +namespace Bit.BlazorUI; /// /// A Sticky is a component that enables elements to stick during scrolling. /// +/// +/// The component is a thin, dependable wrapper over the browser's own position: sticky: the +/// content stays in the normal flow - keeping the room it occupies, so nothing jumps when it pins - +/// until the scroll carries it to an edge of its nearest scrolling container, where it stays while +/// the rest of the content passes. Which edge is either a (the two vertical +/// edges, the two horizontal ones - named Start and End, so they follow the reading direction - or +/// both of a pair) or an exact offset from any of the four sides (, +/// , , ). With none of them set, it sticks +/// to the top. +///
+/// CSS has no event for the moment an element actually pins, so the component derives one: +/// reports the flips, holds the current state, +/// and / are applied only while stuck - which is +/// what a header that casts a shadow only once content passes under it needs. The detection is only +/// wired up when one of those three is used, so a sticky that does not ask for it stays pure CSS. +///
+/// Two things decide whether a sticky element has anywhere to stick at all, and both belong to the +/// markup around it rather than to the component: it pins within its nearest scrolling ancestor +/// (any ancestor with an overflow other than visible becomes that boundary, even one that does not +/// scroll), and it only travels within its own parent - a parent no taller than the element, or a +/// flex parent stretching it, gives it no room to stick in. +///
public partial class BitSticky : BitComponentBase { + private bool _stuck; + private bool _settingUp; + private bool _setupPending; + private string? _attachedId; + private DotNetObjectReference? _dotnetObj; + + [Inject] private IJSRuntime _js { get; set; } = default!; + + + /// /// Specifying the vertical position of a positioned element from bottom. /// + /// + /// A bare number is read as a pixel count; anything else is used as written, so any CSS length + /// ("2rem", "10%", "calc(1rem + 2px)") is accepted. Setting any of the four offsets replaces the + /// default stick-to-top behavior with exactly the edges the offsets name, and an offset set + /// alongside a overrides that side of it. + /// [Parameter, ResetClassBuilder, ResetStyleBuilder] public string? Bottom { get; set; } @@ -19,27 +59,134 @@ public partial class BitSticky : BitComponentBase /// /// Specifying the horizontal position of a positioned element from left. /// + /// + /// A bare number is read as a pixel count; anything else is used as written, so any CSS length + /// is accepted. Horizontal sticking needs a container that scrolls horizontally, and the offset + /// names a physical side - for a side that follows the reading direction, use the Start and End + /// members of instead. + /// [Parameter, ResetClassBuilder, ResetStyleBuilder] public string? Left { get; set; } + /// + /// Callback for when the stuck state of the component changes. The provided value is true while + /// the element is pinned to an edge of its scrolling container. + /// + /// + /// CSS itself has no such event, so the state is derived by a small script watching the scroll. + /// The script is only attached while this callback, or + /// is used, and only while the component is enabled. + /// + [Parameter] public EventCallback OnStuckChanged { get; set; } + /// /// Region to render sticky component in. /// + /// + /// Top, Bottom and TopAndBottom pin the element while the container scrolls vertically; Start, + /// End and StartAndEnd pin it while the container scrolls horizontally, and follow the reading + /// direction (Start is left in LTR and right in RTL). When neither a Position nor any offset is + /// set, the component sticks to the top. + /// [Parameter, ResetClassBuilder] public BitStickyPosition? Position { get; set; } /// /// Specifying the horizontal position of a positioned element from right. /// + /// + /// A bare number is read as a pixel count; anything else is used as written, so any CSS length + /// is accepted. Horizontal sticking needs a container that scrolls horizontally, and the offset + /// names a physical side - for a side that follows the reading direction, use the Start and End + /// members of instead. + /// [Parameter, ResetClassBuilder, ResetStyleBuilder] public string? Right { get; set; } + /// + /// The CSS class applied to the root element only while the component is stuck. + /// + /// + /// This is what styles the pinned state differently from the flowing one - a shadow, an opaque + /// background, a border once content passes underneath. Using it attaches the same stuck + /// detection that drives , and the component also carries the + /// bit-stk-stc class while stuck. + /// + [Parameter, ResetClassBuilder] + public string? StuckClass { get; set; } + + /// + /// The CSS style applied to the root element only while the component is stuck. + /// + /// + /// The inline counterpart of , for a pinned look that is one or two + /// declarations rather than a class. It is appended after every other inline style, so a + /// declaration here wins over the same one in for as long + /// as the element is pinned. Using it attaches the same stuck detection that drives + /// . + /// + [Parameter] public string? StuckStyle { get; set; } + /// /// Specifying the vertical position of a positioned element from top. /// + /// + /// A bare number is read as a pixel count; anything else is used as written, so any CSS length + /// ("2rem", "10%", "calc(1rem + 2px)") is accepted. Setting any of the four offsets replaces the + /// default stick-to-top behavior with exactly the edges the offsets name, and an offset set + /// alongside a overrides that side of it. + /// [Parameter, ResetClassBuilder, ResetStyleBuilder] public string? Top { get; set; } + /// + /// The z-index of the root element, which decides what the pinned content passes over and what + /// passes over it. + /// + /// + /// When not set, the component keeps a z-index of 1 - enough to stay above the plain flowing + /// content it sticks over without covering the popups and overlays of the rest of the page. + /// Raise it where positioned content in the same stacking context has to pass underneath. + /// + [Parameter, ResetStyleBuilder] + public int? ZIndex { get; set; } + + + + /// + /// Gets a value indicating whether the component is currently stuck to an edge of its scrolling + /// container. It is always false unless , + /// or is used, since those are what attach the stuck detection. + /// + public bool IsStuck => _stuck; + + + + /// + /// Called by the scroll script of the component when the stuck state of the element flips. + ///
+ /// This method is not intended to be called from application code. + ///
+ [JSInvokable("OnStuckChange")] + public async Task _OnStuckChange(bool stuck) + { + // The script is disposed asynchronously, so a scroll of the very last frame can still land + // here after the component is gone, where there is nothing left to re-render - or after it + // was disabled, where the report is of a stickiness that is already off and nothing would be + // left to clear the state it latched. + if (IsDisposed || IsEnabled is false) return; + + if (_stuck == stuck) return; + + _stuck = stuck; + + ClassBuilder.Reset(); + + await OnStuckChanged.InvokeAsync(stuck); + + StateHasChanged(); + } + protected override string RootElementClass => "bit-stk"; @@ -58,13 +205,152 @@ protected override void RegisterCssClasses() ? "bit-stk-top" : string.Empty }); + + ClassBuilder.Register(() => _stuck ? "bit-stk-stc" : string.Empty); + + ClassBuilder.Register(() => _stuck ? StuckClass : string.Empty); } protected override void RegisterCssStyles() { - StyleBuilder.Register(() => Top.HasValue() ? $"top: {Top}" : string.Empty); - StyleBuilder.Register(() => Bottom.HasValue() ? $"bottom: {Bottom}" : string.Empty); - StyleBuilder.Register(() => Left.HasValue() ? $"left: {Left}" : string.Empty); - StyleBuilder.Register(() => Right.HasValue() ? $"right: {Right}" : string.Empty); + StyleBuilder.Register(() => Top.HasValue() ? $"top: {GetValueWithUnit(Top)}" : string.Empty); + StyleBuilder.Register(() => Bottom.HasValue() ? $"bottom: {GetValueWithUnit(Bottom)}" : string.Empty); + StyleBuilder.Register(() => Left.HasValue() ? $"left: {GetValueWithUnit(Left)}" : string.Empty); + StyleBuilder.Register(() => Right.HasValue() ? $"right: {GetValueWithUnit(Right)}" : string.Empty); + + StyleBuilder.Register(() => ZIndex.HasValue ? $"z-index: {ZIndex.Value.ToString(CultureInfo.InvariantCulture)}" : string.Empty); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (IsDisposed) return; + + // Setting the script up is a disposal and a setup with the bookkeeping of the attached id in + // between, so a render arriving while one of those awaits is in flight would interleave with + // it and could leave the flag naming a registration that is not the one on the element + // anymore. A render that finds the sequence running only leaves a mark, and the call in + // flight runs it again afterwards, reading the parameters and the id as they are by then. + if (_settingUp) + { + _setupPending = true; + return; + } + + _settingUp = true; + + try + { + do + { + _setupPending = false; + + await SetupStuckDetection(); + } + while (_setupPending && IsDisposed is false); + } + finally + { + // Released even when the interop threw, so the flag cannot keep every later render out of the setup. + _settingUp = false; + } + } + + private async Task SetupStuckDetection() + { + // The script only earns its scroll listener where something observes the state it derives, + // and a disabled sticky is not sticky at all, so there is no state left to derive. + var shouldAttach = IsEnabled && (OnStuckChanged.HasDelegate || StuckClass.HasValue() || StuckStyle.HasValue()); + + var attachId = shouldAttach ? _Id : null; + + if (attachId == _attachedId) return; + + if (_attachedId is not null) + { + await _js.BitStickiesDispose(_attachedId); + + // The component can go away while that call is in flight, and its own disposal has + // released the reference and taken the listeners off the element by the time this + // resumes. Going on from here would hand the script a disposed reference and leave + // behind a registration that nothing is left to dispose. + if (IsDisposed) return; + + _attachedId = null; + } + + if (shouldAttach) + { + _dotnetObj ??= DotNetObjectReference.Create(this); + + await _js.BitStickiesSetup(_Id, _dotnetObj); + + if (IsDisposed) + { + try + { + // The disposal of the component may have run its own cleanup before the setup + // above came back, in which case the registration just made is the one it could + // not see. Disposing an id that is not registered anymore is a no-op, so this is + // safe either way. + await _js.BitStickiesDispose(_Id); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + + return; + } + + _attachedId = _Id; + } + else if (_stuck) + { + // The element is no longer watched, so it must not stay stuck in a state nothing is left + // to update. + _stuck = false; + + ClassBuilder.Reset(); + + StateHasChanged(); + } + } + + /// + /// A CSS length from a parameter that also accepts a bare number, which is read as a pixel count. + /// The number is parsed with the invariant culture, since it is a value written into a stylesheet + /// rather than one shown to a user: read with the current one, "9.5" would be a different length + /// in a culture whose decimal separator is the comma, and none at all in the CSS that came out of it. + /// + private static string? GetValueWithUnit(string? val) + { + if (double.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + return FormattableString.Invariant($"{result}px"); + } + + return val; + } + + + + protected override async ValueTask DisposeAsync(bool disposing) + { + if (IsDisposed || disposing is false) return; + + await base.DisposeAsync(disposing); + + if (_dotnetObj is null) return; + + _dotnetObj.Dispose(); + _dotnetObj = null; + + try + { + // The script is keyed by the id it was attached under, which is not the current one + // anymore when the Id changed after the setup and the component went away before the + // next render could move the registration over. + await _js.BitStickiesDispose(_attachedId ?? _Id); + } + catch (JSDisconnectedException) { } // we can ignore this exception here } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss index 8dc9b56408f..e41e5e90a1e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss @@ -3,6 +3,13 @@ .bit-stk { z-index: 1; position: sticky; + + // A disabled sticky steps back into the normal flow: a static position ignores every inset, so + // the element scrolls away with its content like any other box - which is what makes IsEnabled + // the switch that turns the stickiness itself off. + &.bit-dis { + position: static; + } } .bit-stk-top { @@ -29,4 +36,4 @@ .bit-stk-sae { inset-inline-end: 0; inset-inline-start: 0; -} \ No newline at end of file +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyPosition.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyPosition.cs index 1ced4e8f0d7..38ef3855a6b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyPosition.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyPosition.cs @@ -1,11 +1,41 @@ namespace Bit.BlazorUI; +/// +/// The edges of the scrolling container a BitSticky pins itself to. +/// public enum BitStickyPosition { + /// + /// Sticks to the top edge while the container scrolls vertically. + /// Top, + + /// + /// Sticks to the bottom edge while the container scrolls vertically. + /// Bottom, + + /// + /// Sticks to whichever vertical edge the scroll carries it to: the top while scrolling down past + /// it, the bottom while it is still below the fold. + /// TopAndBottom, + + /// + /// Sticks to the start edge while the container scrolls horizontally - the left edge in LTR, the + /// right edge in RTL. + /// Start, + + /// + /// Sticks to the end edge while the container scrolls horizontally - the right edge in LTR, the + /// left edge in RTL. + /// End, + + /// + /// Sticks to whichever horizontal edge the scroll carries it to, following the reading direction + /// the way Start and End do. + /// StartAndEnd, } diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs new file mode 100644 index 00000000000..02f520949fb --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs @@ -0,0 +1,14 @@ +namespace Bit.BlazorUI; + +internal static class StickiesJsRuntimeExtensions +{ + internal static ValueTask BitStickiesSetup(this IJSRuntime jsRuntime, string id, DotNetObjectReference obj) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Stickies.setup", id, obj); + } + + internal static ValueTask BitStickiesDispose(this IJSRuntime jsRuntime, string id) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Stickies.dispose", id); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts new file mode 100644 index 00000000000..965bad0e2c6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts @@ -0,0 +1,219 @@ +namespace BitBlazorUI { + export class Stickies { + private static _entries = new Map void, + layoutHandler: () => void, + // The scroller is held in a box, since it is re-resolved whenever the layout changes and + // dispose has to take the scroll listener off the target it is bound to at that moment. + target: { current: HTMLElement | Window }, + observer?: ResizeObserver, + // The pending frame is held in a box rather than in a plain field so the handler can keep + // writing to the same object that dispose reads from. + frame: { handle: number } + }>(); + + // Watches a position:sticky element for the moment it actually pins to an edge of its + // scrolling container and reports the flips of that state back to .NET. CSS has no event for + // it, so the state is read off the geometry: a pinned edge sits exactly on the boundary its + // inset names, and an element in the normal flow sits somewhere past it. The state only + // crosses the interop boundary when it flips, so a scroll never costs more than a comparison. + public static setup(id: string, dotnetObj: DotNetObject) { + Stickies.dispose(id); + + const element = document.getElementById(id); + if (!element) return; + + // The scroller is not always the window: any pane with its own overflow scrolls its own + // box, and a scroll event on an element does not bubble to the window. + const target = { current: Stickies.scrollParent(element) }; + + // Starts undefined so the very first evaluation always reports, settling the state of an + // element that is already pinned when it arrives (a restored scroll position, a deep link). + let stuck: boolean | undefined; + + // requestAnimationFrame never hands out a 0 handle, so it doubles as the "no frame pending" mark. + const frame = { handle: 0 }; + + const evaluate = () => { + frame.handle = 0; + + const next = Stickies.isStuck(element, target.current); + + if (next === stuck) return; + + stuck = next; + + // The reference is disposed before the listeners are, so a flip of the very last frame + // can land on a dead reference. The rejection is consumed here rather than left to + // surface as an unhandled one in the console of an application that did nothing wrong. + dotnetObj.invokeMethodAsync('OnStuckChange', stuck).catch(() => { }); + }; + + // rAF coalescing keeps a burst of scroll events down to one evaluation per painted frame. + const scrollHandler = () => { + if (frame.handle) return; + + frame.handle = requestAnimationFrame(evaluate); + }; + + // Which box scrolls the element is not settled once and for all: a pane that had nothing + // to scroll at setup time (so the walk landed on the window) becomes the scroller as soon + // as its content outgrows it, and the other way round. The scroller is re-resolved + // whenever the layout moves, and the scroll listener follows it. + const layoutHandler = () => { + const next = Stickies.scrollParent(element); + + if (next !== target.current) { + target.current.removeEventListener('scroll', scrollHandler); + + target.current = next; + + next.addEventListener('scroll', scrollHandler, { passive: true }); + + observe(); + } + + scrollHandler(); + }; + + target.current.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('resize', layoutHandler, { passive: true }); + + let observer: ResizeObserver | undefined; + + const observe = () => { + if (!observer) return; + + observer.disconnect(); + observer.observe(document.documentElement); + observer.observe(element); + + if (target.current !== window) { + const box = target.current as HTMLElement; + + observer.observe(box); + + // A pane of a fixed height keeps the same border box however much content is put + // into it, so watching the box alone never reports the growth that turns it into + // the scroller. Its content wrapper is the box that actually grows with the content. + if (box.firstElementChild) { + observer.observe(box.firstElementChild); + } + } + }; + + // Content that grows or shrinks on its own (a list that loads more rows, an expanding + // panel) moves the geometry without any scroll event to announce it. + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(layoutHandler); + + observe(); + } + + Stickies._entries.set(id, { element, scrollHandler, layoutHandler, target, observer, frame }); + + // The scroller can already be scrolled when the element arrives, so the state is settled + // once up front instead of waiting for a scroll that may never come. + evaluate(); + } + + public static dispose(id: string) { + const entry = Stickies._entries.get(id); + if (!entry) return; + + entry.target.current.removeEventListener('scroll', entry.scrollHandler); + window.removeEventListener('resize', entry.layoutHandler); + + entry.observer?.disconnect(); + + // A frame scheduled by the last scroll before the disposal would still evaluate and call + // back into a component that is on its way out, so it is dropped along with the listeners. + if (entry.frame.handle) { + cancelAnimationFrame(entry.frame.handle); + + entry.frame.handle = 0; + } + + Stickies._entries.delete(id); + } + + // Whether the element is currently pinned to any of the edges its insets name. Each inset that + // is not auto is a sticky constraint, and a constraint is binding when the matching edge of + // the element sits on (or has been pushed past, by the end of its containing block) the + // boundary of the scrollport that inset measures from. The insets are read off the computed + // style, so the class-based positions and the inline offsets are seen the same way, already + // resolved to physical sides and to pixels. + private static isStuck(element: HTMLElement, target: HTMLElement | Window): boolean { + const style = getComputedStyle(element); + + if (style.position !== 'sticky' && style.position !== '-webkit-sticky') return false; + + const rect = element.getBoundingClientRect(); + + // An element that is not rendered at all (display:none, a collapsed ancestor) reports an + // empty rect at the origin, which would otherwise read as pinned to the top left corner. + if (rect.width === 0 && rect.height === 0) return false; + + // The client sizes rather than the window's inner ones, which include the scrollbars - + // an edge no sticky element can ever be pinned under. + let top = 0; + let left = 0; + let width = document.documentElement.clientWidth; + let height = document.documentElement.clientHeight; + + if (target !== window) { + const box = target as HTMLElement; + const boxRect = box.getBoundingClientRect(); + const boxStyle = getComputedStyle(box); + + const padTop = parseFloat(boxStyle.paddingTop) || 0; + const padLeft = parseFloat(boxStyle.paddingLeft) || 0; + const padRight = parseFloat(boxStyle.paddingRight) || 0; + const padBottom = parseFloat(boxStyle.paddingBottom) || 0; + + // The rect is the border box, and the engine pins a sticky element within the content + // box of its scroller - inside the border (the client offsets) and inside the padding + // as well, as a pinned edge measured in a padded container confirms. + top = boxRect.top + box.clientTop + padTop; + left = boxRect.left + box.clientLeft + padLeft; + width = box.clientWidth - padLeft - padRight; + height = box.clientHeight - padTop - padBottom; + } + + // A percentage inset stays a percentage in the computed style, resolved here against the + // scrollport the way the sticky algorithm resolves it. + const inset = (value: string, size: number) => + value.endsWith('%') ? (parseFloat(value) || 0) * size / 100 : (parseFloat(value) || 0); + + // Half a pixel of tolerance on each comparison: a pinned edge sits exactly on its + // boundary, and sub-pixel layout puts it a fraction to either side of it. + if (style.top !== 'auto' && rect.top <= top + inset(style.top, height) + 0.5) return true; + if (style.bottom !== 'auto' && rect.bottom >= top + height - inset(style.bottom, height) - 0.5) return true; + if (style.left !== 'auto' && rect.left <= left + inset(style.left, width) + 0.5) return true; + if (style.right !== 'auto' && rect.right >= left + width - inset(style.right, width) - 0.5) return true; + + return false; + } + + // The nearest ancestor that actually scrolls, on either axis: the box the element is pinned + // within is its nearest scroll container, and one whose scrollable overflow has nothing to + // scroll never fires a scroll event, so the walk goes past it to the box that really scrolls. + private static scrollParent(element: HTMLElement): HTMLElement | Window { + const scrolls = (overflow: string) => overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay' || overflow === 'hidden'; + + let node = element.parentElement; + + while (node && node !== document.body && node !== document.documentElement) { + const style = getComputedStyle(node); + + if ((scrolls(style.overflowY) && node.scrollHeight > node.clientHeight) || + (scrolls(style.overflowX) && node.scrollWidth > node.clientWidth)) return node; + + node = node.parentElement; + } + + return window; + } + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor index 27aa43b6b61..70d095d901b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor @@ -2,16 +2,24 @@ + Description="A wrapper over the browser's own position:sticky that pins its content to any edge of its scrolling container, with offsets, RTL-aware horizontal sticking, and a derived stuck-state event." />
+
+ With nothing set, a BitSticky sticks to the top of its nearest scrolling container: it + scrolls with the content until it reaches the top edge, and stays pinned there while the rest + passes underneath. The container is whatever ancestor scrolls - a pane with its own overflow + here, or the page itself - and the element only travels within its own parent, so a parent no + taller than the element gives it no room to stick in. +
Basic Sticky
@@ -44,6 +52,13 @@ +
+ Position names the edge the element pins to while its container scrolls vertically. + Top pins it at the top once the scroll reaches it, Bottom keeps it pinned at the + bottom while its place in the content is still below the fold, and TopAndBottom does + whichever the scroll calls for - pinned at the bottom on the way down to it, at the top once + scrolled past it. +
Try scrolling the containers to see the sticky components in action:



Top:
@@ -174,6 +189,12 @@
+
+ The other three members of Position pin the element while its container scrolls + horizontally, and they follow the reading direction rather than naming a physical side: + Start is the left edge in LTR and the right one in RTL, End the opposite, and + StartAndEnd pins to whichever of the two the scroll calls for. +
Try scrolling the containers to see the sticky components in action:



Start:
@@ -205,12 +226,19 @@

Stick to Start and End

- Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.B + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.

+
+ Top and Bottom pin the element at an exact distance from an edge instead of + right on it. A bare number is read as a pixel count, and any CSS length is accepted as + written. Setting any offset replaces the default stick-to-top behavior with exactly the + edges the offsets name, and both can be set at once for a TopAndBottom that keeps a margin + on each side. +
Try scrolling the containers to see the sticky components in action:



Top=20px:
@@ -344,6 +372,12 @@
+
+ Left and Right are the horizontal counterparts of the two offsets above, for a + container that scrolls horizontally. Unlike the Start and End positions they name physical + sides, so they read the same in LTR and RTL - reach for the positions where the side should + follow the reading direction. +
Try scrolling the containers to see the sticky components in action:



Left=20px:
@@ -379,5 +413,236 @@

+ + +
+ CSS has no event for the moment a sticky element actually pins, so BitSticky derives one: + OnStuckChanged reports every flip of the state, the IsStuck property holds the + current value, and StuckClass / StuckStyle are applied to the root element only + while it is stuck (alongside the bit-stk-stc class) - which is what a bar that + casts a shadow only once content passes underneath it needs. The detection is attached only + when one of these three is used, so a sticky that does not ask for it stays pure CSS. +
+
Currently stuck: @isStuck
+
+
StuckClass (a shadow only while stuck):
+
+

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +

+ + @(isStuck ? "Stuck!" : "Not stuck yet") + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+
+
StuckStyle (a tint only while stuck):
+
+

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +

+ + Tinted only while stuck + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+
+ + +
+ A sticky element keeps a z-index of 1, enough to pass over the plain flowing content it + sticks above without covering the popups and overlays of the rest of the page. Where + positioned content in the same container carries a higher stacking order, ZIndex + raises the sticky element above it - in the first container below the sticky passes under + the positioned box, in the second one its ZIndex lifts it over. +
+
Try scrolling the containers to see the sticky components in action:
+


+
Default z-index (passes under the positioned box):
+
+ Default z-index +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. +

+
A positioned box with z-index: 2
+

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. +

+
+


+
ZIndex="3" (passes over the positioned box):
+
+ ZIndex of 3 +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. +

+
A positioned box with z-index: 2
+

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. +

+
+
+ + +
+ IsEnabled is the switch that turns the stickiness itself off: a disabled sticky steps + back into the normal flow and scrolls away with its content like any other element, and its + stuck detection (when it has any) is detached. It is what disabling stickiness conditionally + - below a breakpoint, behind a user preference - looks like without changing the markup. +
+ +
+
+ + @(isStickyEnabled ? "Sticking to the top" : "Scrolling away with the content") + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+
+ + +
+ Style and Class reach the root element, which is both the box that sticks and + the box the content sits in - so a background, a border or a shadow given here travels and + pins with it. For a look that only appears while the element is pinned, use StuckClass + and StuckStyle instead. +
+
Try scrolling the containers to see the sticky components in action:
+


+
Style:
+
+ + Styled Sticky + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+


+
Class:
+
+ Classed Sticky +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+
+ + +
+ The Start and End positions follow the reading direction: in a right-to-left container, + Start is the right edge and End is the left one, with nothing about the markup changing. + Dir sets the direction of the component's own content and is also cascaded, so a + BitSticky inside an RTL container inherits it. +
+
Try scrolling the container to see the sticky component in action:
+


+
Start (the right edge in RTL):
+
+

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+ چسبیده به آغاز +

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+
+


+
End (the left edge in RTL):
+
+

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+ چسبیده به پایان +

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+
+
-
\ No newline at end of file + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs index 7c6c58cb526..c776ca346f2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs @@ -2,6 +2,9 @@ public partial class BitStickyDemo { + private bool isStuck; + private bool isStickyEnabled = true; + private readonly List componentParameters = [ new() @@ -9,7 +12,7 @@ public partial class BitStickyDemo Name = "Bottom", Type = "string?", DefaultValue = "null", - Description = "Specifying the vertical position of a positioned element from bottom." + Description = "The vertical offset the element pins at from the bottom edge. A bare number is read as a pixel count; anything else is used as written, so any CSS length is accepted." }, new() { @@ -23,30 +26,69 @@ public partial class BitStickyDemo Name = "Left", Type = "string?", DefaultValue = "null", - Description = "Specifying the horizontal position of a positioned element from left." + Description = "The horizontal offset the element pins at from the left edge, for a container that scrolls horizontally. A bare number is read as a pixel count; any CSS length is accepted." + }, + new() + { + Name = "OnStuckChanged", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the stuck state changes: true while the element is pinned to an edge of its scrolling container. Using it (or StuckClass/StuckStyle) attaches the stuck detection." + }, + new() + { + Name = "Position", + Type = "BitStickyPosition?", + DefaultValue = "null", + Description = "The edge of the scrolling container the element pins to. Start and End follow the reading direction. When neither a Position nor any offset is set, the component sticks to the top.", + Href = "#sticky-position-enum", + LinkType = LinkType.Link, }, new() { Name = "Right", Type = "string?", DefaultValue = "null", - Description = "Specifying the horizontal position of a positioned element from right." + Description = "The horizontal offset the element pins at from the right edge, for a container that scrolls horizontally. A bare number is read as a pixel count; any CSS length is accepted." }, new() { - Name = "StickyPosition", - Type = "BitStickyPosition", - DefaultValue= "BitStickyPosition.Top", - Description = "Region to render sticky component in.", - Href = "#sticky-position-enum", - LinkType = LinkType.Link, + Name = "StuckClass", + Type = "string?", + DefaultValue = "null", + Description = "The CSS class applied to the root element only while the component is stuck - a shadow, an opaque background, a border once content passes underneath. The bit-stk-stc class accompanies it." + }, + new() + { + Name = "StuckStyle", + Type = "string?", + DefaultValue = "null", + Description = "The CSS style applied to the root element only while the component is stuck, the inline counterpart of StuckClass." }, new() { Name = "Top", Type = "string?", DefaultValue = "null", - Description = "Specifying the vertical position of a positioned element from top." + Description = "The vertical offset the element pins at from the top edge. A bare number is read as a pixel count; anything else is used as written, so any CSS length is accepted." + }, + new() + { + Name = "ZIndex", + Type = "int?", + DefaultValue = "null", + Description = "The z-index of the root element. When not set, the component keeps a z-index of 1 - enough to stay above the plain flowing content it sticks over without covering popups and overlays." + } + ]; + + private readonly List componentPublicMembers = + [ + new() + { + Name = "IsStuck", + Type = "bool", + DefaultValue = "false", + Description = "Whether the component is currently stuck to an edge of its scrolling container. Always false unless OnStuckChanged, StuckClass or StuckStyle is used, since those are what attach the stuck detection." } ]; @@ -56,38 +98,44 @@ public partial class BitStickyDemo { Id = "sticky-position-enum", Name = "BitStickyPosition", - Description = "", + Description = "The edges of the scrolling container a BitSticky pins itself to.", Items = [ new() { Name = "Top", Value = "0", + Description = "Sticks to the top edge while the container scrolls vertically." }, new() { Name = "Bottom", Value = "1", + Description = "Sticks to the bottom edge while the container scrolls vertically." }, new() { Name = "TopAndBottom", Value = "2", + Description = "Sticks to whichever vertical edge the scroll carries it to: the top while scrolling down past it, the bottom while it is still below the fold." }, new() { Name = "Start", Value = "3", + Description = "Sticks to the start edge while the container scrolls horizontally - the left edge in LTR, the right edge in RTL." }, new() { Name = "End", Value = "4", + Description = "Sticks to the end edge while the container scrolls horizontally - the right edge in LTR, the left edge in RTL." }, new() { Name = "StartAndEnd", Value = "5", + Description = "Sticks to whichever horizontal edge the scroll carries it to, following the reading direction the way Start and End do." } ] } @@ -352,7 +400,7 @@ each word has the power to transform into something extraordinary. Here lies the Stick to Start and End

- Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.B + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.

"; @@ -568,5 +616,314 @@ and your voice a reflection of who you are and what you wish to share with the w Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.

"; -} + private readonly string example6RazorCode = @" + + + +
Currently stuck: @isStuck
+ +
+

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +

+ + isStuck = v""> + @(isStuck ? ""Stuck!"" : ""Not stuck yet"") + + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+ + +
+

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +

+ + + Tinted only while stuck + + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
"; + private readonly string example6CsharpCode = @" +private bool isStuck;"; + + private readonly string example7RazorCode = @" + + + +
+ Default z-index + +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. +

+ +
A positioned box with z-index: 2
+ +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. +

+
+ + +
+ ZIndex of 3 + +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. +

+ +
A positioned box with z-index: 2
+ +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. +

+
"; + + private readonly string example8RazorCode = @" + + + + + +
+ + @(isStickyEnabled ? ""Sticking to the top"" : ""Scrolling away with the content"") + + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
"; + private readonly string example8CsharpCode = @" +private bool isStickyEnabled = true;"; + + private readonly string example9RazorCode = @" + + + +
+ + Styled Sticky + + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+ + +
+ Classed Sticky + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
"; + + private readonly string example10RazorCode = @" + + + +
+

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+ + چسبیده به آغاز + +

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+
+ + +
+

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+ + چسبیده به پایان + +

+ روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. +

+
"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.scss index 7a7a0557193..6a2dcf52eed 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.scss @@ -17,6 +17,17 @@ border: 1px solid gray; } +// The positioned neighbor of the ZIndex example: its stacking order sits between the default z-index +// of the component (1) and the raised one the example passes (3), so the two containers show the +// sticky passing under and over the very same box. +.positioned-box { + z-index: 2; + color: black; + padding: 0.5rem; + position: relative; + background-color: cadetblue; +} + ::deep { .sticky { color: black; @@ -24,4 +35,17 @@ background-color: #AAA; border: 1px solid #777; } + + // Passed as the StuckClass, so it lands on markup the component itself writes and needs ::deep. + .stuck-shadow { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); + } + + .custom-class { + color: white; + padding: 0.5rem; + border-radius: 0.5rem; + background-color: darkslateblue; + border: 2px dashed mediumpurple; + } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs index 22bc5a52301..bda6fbbf939 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs @@ -1,4 +1,6 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; namespace Bit.BlazorUI.Tests.Components.Utilities.Sticky; @@ -245,7 +247,7 @@ public void BitStickyShouldRespectChildContent(string childContent) parameters.AddChildContent(childContent); }); - component.MarkupMatches(@$"
{childContent}"); + component.MarkupMatches(@$"
{childContent}
"); } [TestMethod] @@ -430,14 +432,22 @@ public void BitStickyShouldRespectTopBottomLeftRight(string top, string bottom, parameters.Add(p => p.Right, right); }); - if (right.HasValue()) - { - component.MarkupMatches(@$"
"); - } - else + component.MarkupMatches(@$"
"); + } + + [TestMethod, + DataRow("20", "20px"), + DataRow("1.5", "1.5px"), + DataRow("0", "0px") + ] + public void BitStickyShouldReadBareNumberOffsetsAsPixels(string offset, string expected) + { + var component = RenderComponent(parameters => { - component.MarkupMatches(@"
"); - } + parameters.Add(p => p.Top, offset); + }); + + component.MarkupMatches(@$"
"); } [TestMethod, @@ -484,4 +494,173 @@ public void BitStickyShouldRespectPositionChangingAfterRender() component.MarkupMatches(@"
"); } + + [TestMethod] + public void BitStickyShouldRespectPositionAlongsideOffsets() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Position, BitStickyPosition.Top); + parameters.Add(p => p.Top, "10px"); + }); + + component.MarkupMatches(@"
"); + } + + [TestMethod, + DataRow(3), + DataRow(0), + DataRow(-1), + DataRow(null) + ] + public void BitStickyShouldRespectZIndex(int? zIndex) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ZIndex, zIndex); + }); + + if (zIndex.HasValue) + { + component.MarkupMatches(@$"
"); + } + else + { + component.MarkupMatches(@"
"); + } + } + + [TestMethod] + public void BitStickyShouldRespectZIndexChangingAfterRender() + { + var component = RenderComponent(); + + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.ZIndex, 5); + }); + + component.MarkupMatches(@"
"); + } + + [TestMethod] + public void BitStickyShouldNotApplyStuckClassAndStyleWhileNotStuck() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.StuckClass, "my-stuck"); + parameters.Add(p => p.StuckStyle, "color: red"); + }); + + Assert.IsFalse(component.Instance.IsStuck); + + component.MarkupMatches(@"
"); + } + + [TestMethod] + public async Task BitStickyShouldApplyStuckClassAndStyleWhileStuck() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.StuckClass, "my-stuck"); + parameters.Add(p => p.StuckStyle, "color: red"); + }); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + Assert.IsTrue(component.Instance.IsStuck); + + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(false)); + + Assert.IsFalse(component.Instance.IsStuck); + + component.MarkupMatches(@"
"); + } + + [TestMethod] + public async Task BitStickyShouldAppendStuckStyleAfterStyle() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Style, "color: blue"); + parameters.Add(p => p.StuckStyle, "color: red"); + }); + + Assert.AreEqual("color: blue", component.Find("div").GetAttribute("style")); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + // The stuck style has to land after the resting one, since the later declaration of the same + // property is the one an inline style resolves to. + Assert.AreEqual("color: blue;color: red", component.Find("div").GetAttribute("style")); + } + + [TestMethod] + public async Task BitStickyShouldRespectOnStuckChanged() + { + var stuckStates = new List(); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnStuckChanged, (bool stuck) => stuckStates.Add(stuck)); + }); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + CollectionAssert.AreEqual(new List { true }, stuckStates); + Assert.IsTrue(component.Instance.IsStuck); + + // A repeated report of the same state must not raise the callback again. + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + CollectionAssert.AreEqual(new List { true }, stuckStates); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(false)); + + CollectionAssert.AreEqual(new List { true, false }, stuckStates); + Assert.IsFalse(component.Instance.IsStuck); + } + + [TestMethod] + public async Task BitStickyShouldIgnoreStuckReportsWhileDisabled() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + parameters.Add(p => p.StuckClass, "my-stuck"); + }); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + Assert.IsFalse(component.Instance.IsStuck); + + component.MarkupMatches(@"
"); + } + + [TestMethod] + public async Task BitStickyShouldResetStuckStateWhenDisabled() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.StuckClass, "my-stuck"); + }); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.StuckClass, "my-stuck"); + parameters.Add(p => p.IsEnabled, false); + }); + + Assert.IsFalse(component.Instance.IsStuck); + + component.MarkupMatches(@"
"); + } } From 5f9d004ff2b8b324980f7a8d438ad449f70d344f Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 20:00:07 +0330 Subject: [PATCH 2/4] resolve review comments --- .../Utilities/Sticky/BitSticky.razor.cs | 5 + src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts | 95 ++++++++++++++----- .../Utilities/Sticky/BitStickyTests.cs | 25 +++++ 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs index 254d6688f65..3bdd5e6fd19 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs @@ -311,6 +311,11 @@ private async Task SetupStuckDetection() ClassBuilder.Reset(); + // The state flipped, so whoever is watching it hears about it the same way they hear + // about a flip the script reported - the detachment is not a reason to leave an observer + // holding a stuck state the component does not have anymore. + await OnStuckChanged.InvokeAsync(false); + StateHasChanged(); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts index 965bad0e2c6..e51b423bf78 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts @@ -7,6 +7,9 @@ // The scroller is held in a box, since it is re-resolved whenever the layout changes and // dispose has to take the scroll listener off the target it is bound to at that moment. target: { current: HTMLElement | Window }, + // The box the element is pinned within, held in a box of its own for the same reason: it + // is not always the one the scroll events come from, and it is re-resolved just as often. + scope: { current: HTMLElement | Window }, observer?: ResizeObserver, // The pending frame is held in a box rather than in a plain field so the handler can keep // writing to the same object that dispose reads from. @@ -26,7 +29,12 @@ // The scroller is not always the window: any pane with its own overflow scrolls its own // box, and a scroll event on an element does not bubble to the window. - const target = { current: Stickies.scrollParent(element) }; + const target = { current: Stickies.scrollSource(element) }; + + // Which box the element is pinned within is a separate question from which box the scroll + // events come from: a pane that clips its overflow without having anything to scroll is + // still the scrollport the element is pinned in, but it never fires a scroll event. + const scope = { current: Stickies.stickyParent(element) }; // Starts undefined so the very first evaluation always reports, settling the state of an // element that is already pinned when it arrives (a restored scroll position, a deep link). @@ -38,7 +46,7 @@ const evaluate = () => { frame.handle = 0; - const next = Stickies.isStuck(element, target.current); + const next = Stickies.isStuck(element, scope.current); if (next === stuck) return; @@ -60,16 +68,22 @@ // Which box scrolls the element is not settled once and for all: a pane that had nothing // to scroll at setup time (so the walk landed on the window) becomes the scroller as soon // as its content outgrows it, and the other way round. The scroller is re-resolved - // whenever the layout moves, and the scroll listener follows it. + // whenever the layout moves, and the scroll listener follows it. The scrollport is + // re-resolved with it, since a stylesheet can give an ancestor an overflow it did not have. const layoutHandler = () => { - const next = Stickies.scrollParent(element); + const next = Stickies.scrollSource(element); + const nextScope = Stickies.stickyParent(element); + + if (next !== target.current || nextScope !== scope.current) { + if (next !== target.current) { + target.current.removeEventListener('scroll', scrollHandler); - if (next !== target.current) { - target.current.removeEventListener('scroll', scrollHandler); + target.current = next; - target.current = next; + next.addEventListener('scroll', scrollHandler, { passive: true }); + } - next.addEventListener('scroll', scrollHandler, { passive: true }); + scope.current = nextScope; observe(); } @@ -85,22 +99,31 @@ const observe = () => { if (!observer) return; - observer.disconnect(); - observer.observe(document.documentElement); - observer.observe(element); + const watch = observer; - if (target.current !== window) { - const box = target.current as HTMLElement; + const observeBox = (box: HTMLElement | Window) => { + if (box === window) return; - observer.observe(box); + const pane = box as HTMLElement; + + watch.observe(pane); // A pane of a fixed height keeps the same border box however much content is put // into it, so watching the box alone never reports the growth that turns it into // the scroller. Its content wrapper is the box that actually grows with the content. - if (box.firstElementChild) { - observer.observe(box.firstElementChild); + if (pane.firstElementChild) { + watch.observe(pane.firstElementChild); } - } + }; + + watch.disconnect(); + watch.observe(document.documentElement); + watch.observe(element); + + // The two are the same box whenever the scrollport has something to scroll, and + // observing one twice is what the second call already means to the observer. + observeBox(target.current); + observeBox(scope.current); }; // Content that grows or shrinks on its own (a list that loads more rows, an expanding @@ -111,7 +134,7 @@ observe(); } - Stickies._entries.set(id, { element, scrollHandler, layoutHandler, target, observer, frame }); + Stickies._entries.set(id, { element, scrollHandler, layoutHandler, target, scope, observer, frame }); // The scroller can already be scrolled when the element arrives, so the state is settled // once up front instead of waiting for a scroll that may never come. @@ -144,7 +167,7 @@ // boundary of the scrollport that inset measures from. The insets are read off the computed // style, so the class-based positions and the inline offsets are seen the same way, already // resolved to physical sides and to pixels. - private static isStuck(element: HTMLElement, target: HTMLElement | Window): boolean { + private static isStuck(element: HTMLElement, scope: HTMLElement | Window): boolean { const style = getComputedStyle(element); if (style.position !== 'sticky' && style.position !== '-webkit-sticky') return false; @@ -162,8 +185,8 @@ let width = document.documentElement.clientWidth; let height = document.documentElement.clientHeight; - if (target !== window) { - const box = target as HTMLElement; + if (scope !== window) { + const box = scope as HTMLElement; const boxRect = box.getBoundingClientRect(); const boxStyle = getComputedStyle(box); @@ -196,10 +219,32 @@ return false; } - // The nearest ancestor that actually scrolls, on either axis: the box the element is pinned - // within is its nearest scroll container, and one whose scrollable overflow has nothing to - // scroll never fires a scroll event, so the walk goes past it to the box that really scrolls. - private static scrollParent(element: HTMLElement): HTMLElement | Window { + // The scrollport the element is pinned within: its nearest ancestor that is a scroll + // container, whether or not there is anything to scroll in it right now. Every overflow but + // visible and clip makes one, so a pane that clips its content is a box the element can only + // ever be pinned inside of - measured against the viewport behind such a pane instead, an + // element that merely scrolls out of sight with the page reads as pinned to an edge of it. + private static stickyParent(element: HTMLElement): HTMLElement | Window { + const scrolls = (overflow: string) => overflow !== 'visible' && overflow !== 'clip'; + + let node = element.parentElement; + + while (node && node !== document.body && node !== document.documentElement) { + const style = getComputedStyle(node); + + if (scrolls(style.overflowY) || scrolls(style.overflowX)) return node; + + node = node.parentElement; + } + + return window; + } + + // Where the scroll events come from, which is the nearest ancestor that actually scrolls on + // either axis: a scrollport whose scrollable overflow has nothing to scroll never fires a + // scroll event, so a listener on it would be a listener for nothing. Such a box moves with + // whatever scrolls it instead, which is the box this walk goes on to. + private static scrollSource(element: HTMLElement): HTMLElement | Window { const scrolls = (overflow: string) => overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay' || overflow === 'hidden'; let node = element.parentElement; diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs index bda6fbbf939..65f8abd647d 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyTests.cs @@ -663,4 +663,29 @@ public async Task BitStickyShouldResetStuckStateWhenDisabled() component.MarkupMatches(@"
"); } + + [TestMethod] + public async Task BitStickyShouldReportUnstuckWhenDetectionIsDetached() + { + var stuckStates = new List(); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnStuckChanged, (bool stuck) => stuckStates.Add(stuck)); + }); + + await component.InvokeAsync(() => component.Instance._OnStuckChange(true)); + + CollectionAssert.AreEqual(new[] { true }, stuckStates); + + component.Render(parameters => + { + parameters.Add(p => p.OnStuckChanged, (bool stuck) => stuckStates.Add(stuck)); + parameters.Add(p => p.IsEnabled, false); + }); + + Assert.IsFalse(component.Instance.IsStuck); + + CollectionAssert.AreEqual(new[] { true, false }, stuckStates); + } } From 7208c5322f4220e68d0ee74af5c813dea9aac8e2 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Thu, 3 Sep 2026 13:47:29 +0330 Subject: [PATCH 3/4] local review --- .../{BitSticky.razor.cs => BitSticky.cs} | 266 +++++++++++++--- .../Utilities/Sticky/BitSticky.razor | 12 - .../Utilities/Sticky/BitSticky.scss | 7 +- .../Utilities/Sticky/BitStickyEdges.cs | 41 +++ .../Utilities/Sticky/BitStickyParams.cs | 153 +++++++++ .../JsInterop/StickiesJsRuntimeExtensions.cs | 5 + src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts | 174 ++++++++-- .../Utilities/Sticky/BitStickyDemo.razor | 117 ++++++- .../Utilities/Sticky/BitStickyDemo.razor.cs | 221 ++++++++++++- .../Utilities/Sticky/BitStickyDemo.razor.scss | 28 ++ .../Sticky/BitStickyCascadingParamsTest.razor | 18 ++ .../Sticky/BitStickyHtmlAttributesTest.razor | 14 +- .../Utilities/Sticky/BitStickyTests.cs | 300 +++++++++++++++++- 13 files changed, 1244 insertions(+), 112 deletions(-) rename src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/{BitSticky.razor.cs => BitSticky.cs} (54%) delete mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyEdges.cs create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyParams.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Sticky/BitStickyCascadingParamsTest.razor diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.cs similarity index 54% rename from src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs rename to src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.cs index 3bdd5e6fd19..40a1a71072d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.CompilerServices; namespace Bit.BlazorUI; @@ -16,10 +18,13 @@ namespace Bit.BlazorUI; /// to the top. ///
/// CSS has no event for the moment an element actually pins, so the component derives one: -/// reports the flips, holds the current state, -/// and / are applied only while stuck - which is -/// what a header that casts a shadow only once content passes under it needs. The detection is only -/// wired up when one of those three is used, so a sticky that does not ask for it stays pure CSS. +/// reports the flips, the edge that +/// holds it, and hold the current state, and +/// / are applied only while stuck - which is what +/// a header that casts a shadow only once content passes under it needs. An element that has not +/// moved is never reported as pinned, however exactly it happens to rest on an edge, so the header of +/// a container nobody has scrolled yet is not stuck. The detection is only wired up when one of those +/// members is used, so a sticky that does not ask for it stays pure CSS. ///
/// Two things decide whether a sticky element has anywhere to stick at all, and both belong to the /// markup around it rather than to the component: it pins within its nearest scrolling ancestor @@ -33,12 +38,27 @@ public partial class BitSticky : BitComponentBase private bool _settingUp; private bool _setupPending; private string? _attachedId; + private string? _attachedElement; + private BitStickyEdges _edges; private DotNetObjectReference? _dotnetObj; [Inject] private IJSRuntime _js { get; set; } = default!; + /// + /// Gets or sets the cascading parameters for the sticky component. + /// + /// + /// This property receives its value from an ancestor component via Blazor's cascading parameter mechanism. + ///
+ /// The intended use is to allow shared configuration or settings to be applied to multiple sticky components through the component. + ///
+ [CascadingParameter(Name = BitStickyParams.ParamName)] + public BitStickyParams? CascadingParameters { get; set; } + + + /// /// Specifying the vertical position of a positioned element from bottom. /// @@ -56,6 +76,23 @@ public partial class BitSticky : BitComponentBase /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// The custom html element used for the root node. The default is "div". + /// + /// + /// A sticky element is very often one HTML already has a name for - the "header" of a page or of + /// a pane, the "footer" that keeps a toolbar in reach, the "nav" of a table of contents beside an + /// article, the "aside" of a sidebar, or the "th" and "tr" of a frozen table header - and the name + /// is what tells assistive technologies which of them it is. The tag decides nothing about the + /// stickiness itself: every parameter of the component works the same whichever one is rendered. + ///
+ /// The name is used as written, but only while it is a name a tag can have - a letter followed by + /// letters, digits and the "-", "_", "." and ":" that join them. Anything else falls back to the + /// default tag, since a name carrying whitespace or a "<" would be a way to write markup rather + /// than to name an element. + ///
+ [Parameter] public string? Element { get; set; } + /// /// Specifying the horizontal position of a positioned element from left. /// @@ -74,11 +111,30 @@ public partial class BitSticky : BitComponentBase /// /// /// CSS itself has no such event, so the state is derived by a small script watching the scroll. - /// The script is only attached while this callback, or - /// is used, and only while the component is enabled. + /// The script is only attached while this callback, , + /// or is used, and only while the component is + /// enabled. + ///
+ /// This reports only that the element is pinned, not to what: an element that moves from one edge + /// of a pair to the other stays stuck throughout and raises nothing here. + /// is what reports that move. ///
[Parameter] public EventCallback OnStuckChanged { get; set; } + /// + /// Callback for when the set of edges the component is pinned to changes. + /// + /// + /// This is the finer grained half of : it names the edges rather than + /// only reporting that there are some, so a bar pinned by a that + /// holds a pair of them can tell which of the two is holding it - which side to cast its shadow + /// toward, which border to draw - and it also reports the move from one of them to the other, + /// which never flips the boolean. The edges are physical, so a Start sticky reports + /// in a left-to-right container and + /// in a right-to-left one. + /// + [Parameter] public EventCallback OnStuckEdgesChanged { get; set; } + /// /// Region to render sticky component in. /// @@ -110,7 +166,9 @@ public partial class BitSticky : BitComponentBase /// This is what styles the pinned state differently from the flowing one - a shadow, an opaque /// background, a border once content passes underneath. Using it attaches the same stuck /// detection that drives , and the component also carries the - /// bit-stk-stc class while stuck. + /// bit-stk-stc class while stuck, plus one naming each edge that holds it + /// (bit-stk-stc-top, bit-stk-stc-btm, bit-stk-stc-lft, bit-stk-stc-rgt), + /// which is what a shadow that has to fall away from the edge it is pinned to selects on. /// [Parameter, ResetClassBuilder] public string? StuckClass { get; set; } @@ -146,7 +204,9 @@ public partial class BitSticky : BitComponentBase /// /// When not set, the component keeps a z-index of 1 - enough to stay above the plain flowing /// content it sticks over without covering the popups and overlays of the rest of the page. - /// Raise it where positioned content in the same stacking context has to pass underneath. + /// Raise it where positioned content in the same stacking context has to pass underneath. The + /// same default is also the --bit-stk-zin custom property, for setting it from a + /// stylesheet rather than per component. /// [Parameter, ResetStyleBuilder] public int? ZIndex { get; set; } @@ -155,20 +215,56 @@ public partial class BitSticky : BitComponentBase /// /// Gets a value indicating whether the component is currently stuck to an edge of its scrolling - /// container. It is always false unless , - /// or is used, since those are what attach the stuck detection. + /// container. It is always false unless , + /// , or is + /// used, since those are what attach the stuck detection. /// public bool IsStuck => _stuck; + /// + /// Gets the edges of the scrolling container the component is currently pinned to. + /// + /// + /// This is with the edges named: it is + /// exactly while that one is false, and it carries the two + /// edges that meet in a corner while the element is pinned into one. Like , + /// it stays None unless one of the members that attach the stuck detection is used. + /// + public BitStickyEdges StuckEdges => _edges; + + + + /// + /// Reads the stuck state of the component again, along with everything it is derived from. + /// + /// + /// The state settles itself: it is read on every scroll of the container, and again whenever the + /// element, its parent, the scrolling container or the page changes size. What is left over is a + /// layout change none of those can see - content moved around inside the container without any of + /// the watched boxes changing size - and this is what such a change is answered with. It does + /// nothing while the detection is not attached, and nothing before the first render. + /// + public async ValueTask RefreshAsync() + { + if (IsDisposed || _attachedId is null) return; + + try + { + await _js.BitStickiesRefresh(_attachedId); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } + /// - /// Called by the scroll script of the component when the stuck state of the element flips. + /// Called by the scroll script of the component when the edges the element is pinned to change. ///
/// This method is not intended to be called from application code. ///
+ /// The flags the script has resolved. [JSInvokable("OnStuckChange")] - public async Task _OnStuckChange(bool stuck) + public async Task _OnStuckChange(int edges) { // The script is disposed asynchronously, so a scroll of the very last frame can still land // here after the component is gone, where there is nothing left to re-render - or after it @@ -176,15 +272,7 @@ public async Task _OnStuckChange(bool stuck) // left to clear the state it latched. if (IsDisposed || IsEnabled is false) return; - if (_stuck == stuck) return; - - _stuck = stuck; - - ClassBuilder.Reset(); - - await OnStuckChanged.InvokeAsync(stuck); - - StateHasChanged(); + await SetStuckEdges((BitStickyEdges)edges); } @@ -208,6 +296,13 @@ protected override void RegisterCssClasses() ClassBuilder.Register(() => _stuck ? "bit-stk-stc" : string.Empty); + // One class per edge that holds the element, so a pinned look can be told apart by the side it + // is pinned to without a callback and a field to remember it in. + ClassBuilder.Register(() => (_edges & BitStickyEdges.Top) == 0 ? string.Empty : "bit-stk-stc-top"); + ClassBuilder.Register(() => (_edges & BitStickyEdges.Bottom) == 0 ? string.Empty : "bit-stk-stc-btm"); + ClassBuilder.Register(() => (_edges & BitStickyEdges.Left) == 0 ? string.Empty : "bit-stk-stc-lft"); + ClassBuilder.Register(() => (_edges & BitStickyEdges.Right) == 0 ? string.Empty : "bit-stk-stc-rgt"); + ClassBuilder.Register(() => _stuck ? StuckClass : string.Empty); } @@ -221,6 +316,42 @@ protected override void RegisterCssStyles() StyleBuilder.Register(() => ZIndex.HasValue ? $"z-index: {ZIndex.Value.ToString(CultureInfo.InvariantCulture)}" : string.Empty); } + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitStickyParams))] + protected override void OnParametersSet() + { + CascadingParameters?.UpdateParameters(this); + + base.OnParametersSet(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, GetElement()); + builder.AddMultipleAttributes(1, RuntimeHelpers.TypeCheck(HtmlAttributes)); + builder.AddAttribute(2, "id", _Id); + // A null value here is not the same as nothing at all: the builder still records the name and + // drops the attribute of the same name that came out of HtmlAttributes, so the two that are + // not always written are only added while the parameter itself carries a value, and the + // splatted one is left alone otherwise. + if (AriaLabel is not null) + { + builder.AddAttribute(3, "aria-label", AriaLabel); + } + if (Dir is not null) + { + builder.AddAttribute(4, "dir", Dir.Value.ToString().ToLowerInvariant()); + } + // The stuck style is appended after every other inline style, since the later declaration of + // the same property is the one an inline style resolves to. + builder.AddAttribute(5, "style", _stuck ? JoinStyles(StyleBuilder.Value, StuckStyle) : StyleBuilder.Value); + builder.AddAttribute(6, "class", ClassBuilder.Value); + builder.AddElementReferenceCapture(7, v => RootElement = v); + builder.AddContent(8, ChildContent); + builder.CloseElement(); + + base.BuildRenderTree(builder); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -257,15 +388,80 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + + + // The tag the root element is rendered as. A name that is not one a tag can have is not used at + // all, since a name carrying whitespace or a "<" would write markup of its own rather than name + // an element, and one carrying another symbol is a name document.createElement may refuse, which + // throws where the renderer builds the element and takes the whole render batch with it. + private string GetElement() + { + var element = Element?.Trim(); + + if (element.HasNoValue()) return "div"; + + if (char.IsAsciiLetter(element![0]) is false) return "div"; + + foreach (var @char in element) + { + if (char.IsAsciiLetterOrDigit(@char)) continue; + + if (@char is '-' or '_' or '.' or ':') continue; + + // Everything outside ASCII that is a letter or a digit is a name of some alphabet; the + // rest of it - the separators, the punctuation, the C1 controls - is refused along with + // the ASCII symbols and whitespace. + if (char.IsAscii(@char) is false && char.IsLetterOrDigit(@char)) continue; + + return "div"; + } + + return element; + } + + // The one place the derived state is written, so the boolean, the edges, the classes and the two + // callbacks can never disagree about it. + private async Task SetStuckEdges(BitStickyEdges edges) + { + if (_edges == edges) return; + + var stuck = edges != BitStickyEdges.None; + var flipped = _stuck != stuck; + + _edges = edges; + _stuck = stuck; + + ClassBuilder.Reset(); + + // The boolean is raised first and only where it actually changed: an element carried from one + // edge of a pair to the other never stopped being stuck. + if (flipped) + { + await OnStuckChanged.InvokeAsync(stuck); + } + + await OnStuckEdgesChanged.InvokeAsync(edges); + + StateHasChanged(); + } + private async Task SetupStuckDetection() { // The script only earns its scroll listener where something observes the state it derives, // and a disabled sticky is not sticky at all, so there is no state left to derive. - var shouldAttach = IsEnabled && (OnStuckChanged.HasDelegate || StuckClass.HasValue() || StuckStyle.HasValue()); + var shouldAttach = IsEnabled && (OnStuckChanged.HasDelegate || + OnStuckEdgesChanged.HasDelegate || + StuckClass.HasValue() || + StuckStyle.HasValue()); var attachId = shouldAttach ? _Id : null; - if (attachId == _attachedId) return; + // The script holds the element it found under that id, and a change of tag does not change + // the element - it replaces it, leaving the registration watching a node that is not in the + // document anymore. So the tag is half of what the registration is keyed by. + var attachElement = shouldAttach ? GetElement() : null; + + if (attachId == _attachedId && attachElement == _attachedElement) return; if (_attachedId is not null) { @@ -278,6 +474,7 @@ private async Task SetupStuckDetection() if (IsDisposed) return; _attachedId = null; + _attachedElement = null; } if (shouldAttach) @@ -302,21 +499,15 @@ private async Task SetupStuckDetection() } _attachedId = _Id; + _attachedElement = attachElement; } - else if (_stuck) + else if (_edges != BitStickyEdges.None) { // The element is no longer watched, so it must not stay stuck in a state nothing is left - // to update. - _stuck = false; - - ClassBuilder.Reset(); - - // The state flipped, so whoever is watching it hears about it the same way they hear - // about a flip the script reported - the detachment is not a reason to leave an observer - // holding a stuck state the component does not have anymore. - await OnStuckChanged.InvokeAsync(false); - - StateHasChanged(); + // to update. The state flipped, so whoever is watching it hears about it the same way + // they hear about a flip the script reported - the detachment is not a reason to leave an + // observer holding a stuck state the component does not have anymore. + await SetStuckEdges(BitStickyEdges.None); } } @@ -328,7 +519,10 @@ private async Task SetupStuckDetection() /// private static string? GetValueWithUnit(string? val) { - if (double.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + // The infinities and the not-a-number that double.TryParse also accepts by name are numbers no + // length can be written of, so they are left to the stylesheet as the words they were given as. + if (double.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out double result) && + double.IsFinite(result)) { return FormattableString.Invariant($"{result}px"); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor deleted file mode 100644 index 48882079a82..00000000000 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.razor +++ /dev/null @@ -1,12 +0,0 @@ -@namespace Bit.BlazorUI -@inherits BitComponentBase - -
- @ChildContent -
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss index e41e5e90a1e..f7a99e16b35 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitSticky.scss @@ -1,7 +1,12 @@ @import "../../../Styles/functions.scss"; .bit-stk { - z-index: 1; + // Enough to pass over the plain flowing content the element sticks above without covering the + // popups and overlays of the rest of the page, which sit far higher up the scale. Declared as a + // custom property so a stylesheet can raise it for a whole region at once, the way the ZIndex + // parameter raises it for one component. + --bit-stk-zin: 1; + z-index: var(--bit-stk-zin); position: sticky; // A disabled sticky steps back into the normal flow: a static position ignores every inset, so diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyEdges.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyEdges.cs new file mode 100644 index 00000000000..ba031a88498 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyEdges.cs @@ -0,0 +1,41 @@ +namespace Bit.BlazorUI; + +/// +/// The edges of the scrolling container a BitSticky is currently pinned to. +/// +/// +/// These are the physical edges of the scrollport, the way the browser resolves them: a +/// sticky reports in a left-to-right +/// container and in a right-to-left one. +///
+/// More than one of them can be set at once, since an element pinned into a corner is held by the +/// two edges that meet there. +///
+[Flags] +public enum BitStickyEdges +{ + /// + /// The element is not pinned: it is travelling with the content of its scrolling container. + /// + None = 0, + + /// + /// The element is pinned to the top edge of its scrolling container. + /// + Top = 1, + + /// + /// The element is pinned to the bottom edge of its scrolling container. + /// + Bottom = 2, + + /// + /// The element is pinned to the left edge of its scrolling container. + /// + Left = 4, + + /// + /// The element is pinned to the right edge of its scrolling container. + /// + Right = 8, +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyParams.cs new file mode 100644 index 00000000000..87b055ae7b9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Sticky/BitStickyParams.cs @@ -0,0 +1,153 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for component. +/// +/// +/// What belongs here is what every sticky of a page or of an app agrees on - which edge they pin to, +/// how far from it, how they look while pinned, what they pass over. The content and the callbacks +/// are deliberately not here: they are what makes one sticky the one it is, and cascading them would +/// give every sticky on the page the same content and the same observer. +/// +public class BitStickyParams : BitComponentBaseParams, IBitComponentParams +{ + /// + /// Represents the parameter name used to identify the cascading parameters within . + /// + /// + /// This constant is typically used when referencing or accessing the BitSticky value in + /// parameterized APIs or configuration settings. Using this constant helps ensure consistency and reduces the risk + /// of typographical errors. + /// + public const string ParamName = $"{nameof(BitParams)}.{nameof(BitSticky)}"; + + + + public string Name => ParamName; + + + + /// + /// Gets or sets the vertical offset the element pins at from the bottom edge. + /// + public string? Bottom { get; set; } + + /// + /// Gets or sets the custom html element used for the root node. + /// + public string? Element { get; set; } + + /// + /// Gets or sets the horizontal offset the element pins at from the left edge. + /// + public string? Left { get; set; } + + /// + /// Gets or sets the edge of the scrolling container the element pins to. + /// + public BitStickyPosition? Position { get; set; } + + /// + /// Gets or sets the horizontal offset the element pins at from the right edge. + /// + public string? Right { get; set; } + + /// + /// Gets or sets the CSS class applied to the root element only while the component is stuck. + /// + public string? StuckClass { get; set; } + + /// + /// Gets or sets the CSS style applied to the root element only while the component is stuck. + /// + public string? StuckStyle { get; set; } + + /// + /// Gets or sets the vertical offset the element pins at from the top edge. + /// + public string? Top { get; set; } + + /// + /// Gets or sets the z-index of the root element. + /// + public int? ZIndex { get; set; } + + + + /// + /// Updates the properties of the specified instance with any values that have been set on + /// this object, if those properties have not already been set on the itself. + /// + /// + /// The instance whose properties will be updated. Cannot be null. + /// + public void UpdateParameters(BitSticky bitSticky) + { + if (bitSticky is null) return; + + UpdateBaseParameters(bitSticky); + + if (Bottom.HasValue() && bitSticky.HasNotBeenSet(nameof(Bottom))) + { + bitSticky.Bottom = Bottom; + + bitSticky.ClassBuilder.Reset(); + bitSticky.StyleBuilder.Reset(); + } + + if (Element.HasValue() && bitSticky.HasNotBeenSet(nameof(Element))) + { + bitSticky.Element = Element; + } + + if (Left.HasValue() && bitSticky.HasNotBeenSet(nameof(Left))) + { + bitSticky.Left = Left; + + bitSticky.ClassBuilder.Reset(); + bitSticky.StyleBuilder.Reset(); + } + + if (Position.HasValue && bitSticky.HasNotBeenSet(nameof(Position))) + { + bitSticky.Position = Position.Value; + + bitSticky.ClassBuilder.Reset(); + } + + if (Right.HasValue() && bitSticky.HasNotBeenSet(nameof(Right))) + { + bitSticky.Right = Right; + + bitSticky.ClassBuilder.Reset(); + bitSticky.StyleBuilder.Reset(); + } + + if (StuckClass.HasValue() && bitSticky.HasNotBeenSet(nameof(StuckClass))) + { + bitSticky.StuckClass = StuckClass; + + bitSticky.ClassBuilder.Reset(); + } + + if (StuckStyle.HasValue() && bitSticky.HasNotBeenSet(nameof(StuckStyle))) + { + bitSticky.StuckStyle = StuckStyle; + } + + if (Top.HasValue() && bitSticky.HasNotBeenSet(nameof(Top))) + { + bitSticky.Top = Top; + + bitSticky.ClassBuilder.Reset(); + bitSticky.StyleBuilder.Reset(); + } + + if (ZIndex.HasValue && bitSticky.HasNotBeenSet(nameof(ZIndex))) + { + bitSticky.ZIndex = ZIndex.Value; + + bitSticky.StyleBuilder.Reset(); + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs index 02f520949fb..22f68037550 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/StickiesJsRuntimeExtensions.cs @@ -7,6 +7,11 @@ internal static ValueTask BitStickiesSetup(this IJSRuntime jsRuntime, string id, return jsRuntime.InvokeVoid("BitBlazorUI.Stickies.setup", id, obj); } + internal static ValueTask BitStickiesRefresh(this IJSRuntime jsRuntime, string id) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Stickies.refresh", id); + } + internal static ValueTask BitStickiesDispose(this IJSRuntime jsRuntime, string id) { return jsRuntime.InvokeVoid("BitBlazorUI.Stickies.dispose", id); diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts index e51b423bf78..ac1b19f86ea 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Stickies.ts @@ -1,7 +1,13 @@ -namespace BitBlazorUI { +namespace BitBlazorUI { export class Stickies { + // The physical edges of the scrollport an element can be pinned to, as the flags of the + // BitStickyEdges enum the component reads the reported number back into. + private static readonly EDGE_TOP = 1; + private static readonly EDGE_BOTTOM = 2; + private static readonly EDGE_LEFT = 4; + private static readonly EDGE_RIGHT = 8; + private static _entries = new Map void, layoutHandler: () => void, // The scroller is held in a box, since it is re-resolved whenever the layout changes and @@ -18,8 +24,8 @@ // Watches a position:sticky element for the moment it actually pins to an edge of its // scrolling container and reports the flips of that state back to .NET. CSS has no event for - // it, so the state is read off the geometry: a pinned edge sits exactly on the boundary its - // inset names, and an element in the normal flow sits somewhere past it. The state only + // it, so the state is derived from two readings that together say what the browser itself + // would: where the element is, and where it would be with nothing pinning it. The state only // crosses the interop boundary when it flips, so a scroll never costs more than a comparison. public static setup(id: string, dotnetObj: DotNetObject) { Stickies.dispose(id); @@ -36,9 +42,16 @@ // still the scrollport the element is pinned in, but it never fires a scroll event. const scope = { current: Stickies.stickyParent(element) }; - // Starts undefined so the very first evaluation always reports, settling the state of an - // element that is already pinned when it arrives (a restored scroll position, a deep link). - let stuck: boolean | undefined; + // Where the element sits in the flow of that scrollport, which is what the pinning moves + // it away from and the one thing a pinned element cannot be measured back to. It is a + // property of the layout rather than of the scroll, so it is read once and again whenever + // the layout moves; null marks it as owed. + let flow: { top: number, left: number } | null = null; + + // Starts negative - a value no set of flags can take - so the very first evaluation always + // reports, settling the state of an element that is already pinned when it arrives (a + // restored scroll position, a deep link). + let edges = -1; // requestAnimationFrame never hands out a 0 handle, so it doubles as the "no frame pending" mark. const frame = { handle: 0 }; @@ -46,16 +59,30 @@ const evaluate = () => { frame.handle = 0; - const next = Stickies.isStuck(element, scope.current); + if (flow === null) { + flow = Stickies.flowPosition(element, scope.current); + } - if (next === stuck) return; + let next = Stickies.stuckEdges(element, scope.current, flow); - stuck = next; + // A flip is where a flow position the content has moved on from would show, so it is + // read again before any flip is believed, and the corrected reading settles the state + // within the same frame. A reading that was right costs one more of the same reading + // and still flips once; a stale one is replaced by the measurement that catches it. + if (edges >= 0 && next !== edges) { + flow = Stickies.flowPosition(element, scope.current); + + next = Stickies.stuckEdges(element, scope.current, flow); + } + + if (next === edges) return; + + edges = next; // The reference is disposed before the listeners are, so a flip of the very last frame // can land on a dead reference. The rejection is consumed here rather than left to // surface as an unhandled one in the console of an application that did nothing wrong. - dotnetObj.invokeMethodAsync('OnStuckChange', stuck).catch(() => { }); + dotnetObj.invokeMethodAsync('OnStuckChange', edges).catch(() => { }); }; // rAF coalescing keeps a burst of scroll events down to one evaluation per painted frame. @@ -88,6 +115,12 @@ observe(); } + // Content that moved is content the flow position was read before, so the reading is + // owed again. It is left to the frame the scroll handler below schedules rather than + // taken here, since this also runs from a resize observer, and a measurement that + // invalidates the very layout it reads is what makes an observer loop of one. + flow = null; + scrollHandler(); }; @@ -101,8 +134,8 @@ const watch = observer; - const observeBox = (box: HTMLElement | Window) => { - if (box === window) return; + const observeBox = (box: HTMLElement | Window | null) => { + if (!box || box === window) return; const pane = box as HTMLElement; @@ -120,6 +153,10 @@ watch.observe(document.documentElement); watch.observe(element); + // The parent is the box the element travels within, so anything growing or shrinking + // inside it moves the flow position the state is measured against. + observeBox(element.parentElement); + // The two are the same box whenever the scrollport has something to scroll, and // observing one twice is what the second call already means to the observer. observeBox(target.current); @@ -134,13 +171,21 @@ observe(); } - Stickies._entries.set(id, { element, scrollHandler, layoutHandler, target, scope, observer, frame }); + Stickies._entries.set(id, { scrollHandler, layoutHandler, target, scope, observer, frame }); // The scroller can already be scrolled when the element arrives, so the state is settled // once up front instead of waiting for a scroll that may never come. evaluate(); } + // Reads everything the state is derived from again: which box scrolls the element, which one + // it is pinned within, where it sits in the flow of that one, and the state itself. This is + // what a layout change no observer can see - one that leaves every watched box the size it + // was, such as content moved around inside the scrollport - is answered with. + public static refresh(id: string) { + Stickies._entries.get(id)?.layoutHandler(); + } + public static dispose(id: string) { const entry = Stickies._entries.get(id); if (!entry) return; @@ -161,22 +206,26 @@ Stickies._entries.delete(id); } - // Whether the element is currently pinned to any of the edges its insets name. Each inset that - // is not auto is a sticky constraint, and a constraint is binding when the matching edge of - // the element sits on (or has been pushed past, by the end of its containing block) the - // boundary of the scrollport that inset measures from. The insets are read off the computed - // style, so the class-based positions and the inline offsets are seen the same way, already - // resolved to physical sides and to pixels. - private static isStuck(element: HTMLElement, scope: HTMLElement | Window): boolean { + // The edges the element is currently pinned to, as the flags the component reads back. An edge + // holds the element when two things are true of it at once, and neither of them says so alone: + // the matching edge of the element sits on the boundary of the scrollport that its inset + // measures from, and the element has been carried away from where the flow would have put it. + // Without the first, an element pushed back out of the scrollport by the end of its containing + // block - still offset, but on its way out of sight - would read as pinned; without the second, + // so would one that has never moved at all and only happens to rest on that boundary, which is + // every sticky header of a container nobody has scrolled yet. + private static stuckEdges(element: HTMLElement, scope: HTMLElement | Window, flow: { top: number, left: number }): number { const style = getComputedStyle(element); - if (style.position !== 'sticky' && style.position !== '-webkit-sticky') return false; + if (style.position !== 'sticky' && style.position !== '-webkit-sticky') return 0; const rect = element.getBoundingClientRect(); // An element that is not rendered at all (display:none, a collapsed ancestor) reports an // empty rect at the origin, which would otherwise read as pinned to the top left corner. - if (rect.width === 0 && rect.height === 0) return false; + if (rect.width === 0 && rect.height === 0) return 0; + + const origin = Stickies.scopeOrigin(scope); // The client sizes rather than the window's inner ones, which include the scrollbars - // an edge no sticky element can ever be pinned under. @@ -187,7 +236,6 @@ if (scope !== window) { const box = scope as HTMLElement; - const boxRect = box.getBoundingClientRect(); const boxStyle = getComputedStyle(box); const padTop = parseFloat(boxStyle.paddingTop) || 0; @@ -195,28 +243,88 @@ const padRight = parseFloat(boxStyle.paddingRight) || 0; const padBottom = parseFloat(boxStyle.paddingBottom) || 0; - // The rect is the border box, and the engine pins a sticky element within the content + // The origin is the border box, and the engine pins a sticky element within the content // box of its scroller - inside the border (the client offsets) and inside the padding // as well, as a pinned edge measured in a padded container confirms. - top = boxRect.top + box.clientTop + padTop; - left = boxRect.left + box.clientLeft + padLeft; + top = origin.top + box.clientTop + padTop; + left = origin.left + box.clientLeft + padLeft; width = box.clientWidth - padLeft - padRight; height = box.clientHeight - padTop - padBottom; } + // How far the pinning has carried the element from its place in the flow, measured in the + // frame of the content of the scrollport - so that scrolling alone, which moves the element + // and its flow position together, never shows up in it and only the pinning does. + const shiftY = (rect.top - origin.top + origin.scrollTop) - flow.top; + const shiftX = (rect.left - origin.left + origin.scrollLeft) - flow.left; + // A percentage inset stays a percentage in the computed style, resolved here against the // scrollport the way the sticky algorithm resolves it. const inset = (value: string, size: number) => value.endsWith('%') ? (parseFloat(value) || 0) * size / 100 : (parseFloat(value) || 0); // Half a pixel of tolerance on each comparison: a pinned edge sits exactly on its - // boundary, and sub-pixel layout puts it a fraction to either side of it. - if (style.top !== 'auto' && rect.top <= top + inset(style.top, height) + 0.5) return true; - if (style.bottom !== 'auto' && rect.bottom >= top + height - inset(style.bottom, height) - 0.5) return true; - if (style.left !== 'auto' && rect.left <= left + inset(style.left, width) + 0.5) return true; - if (style.right !== 'auto' && rect.right >= left + width - inset(style.right, width) - 0.5) return true; + // boundary, and sub-pixel layout puts it a fraction to either side of it. The same + // tolerance on the shift, where it is what tells a pinned element from a resting one. + let edges = 0; + + if (shiftY > 0.5 && style.top !== 'auto' && rect.top <= top + inset(style.top, height) + 0.5) { + edges |= Stickies.EDGE_TOP; + } + + if (shiftY < -0.5 && style.bottom !== 'auto' && rect.bottom >= top + height - inset(style.bottom, height) - 0.5) { + edges |= Stickies.EDGE_BOTTOM; + } + + if (shiftX > 0.5 && style.left !== 'auto' && rect.left <= left + inset(style.left, width) + 0.5) { + edges |= Stickies.EDGE_LEFT; + } + + if (shiftX < -0.5 && style.right !== 'auto' && rect.right >= left + width - inset(style.right, width) - 0.5) { + edges |= Stickies.EDGE_RIGHT; + } + + return edges; + } + + // Where the element sits in the flow of its scrollport, which is the one thing about a pinned + // element that cannot be read off it while it is pinned: every geometry it reports carries the + // sticky offset already, down to offsetTop. So the offset is taken off for the length of a + // single measurement - a sticky box and a static one are laid out in exactly the same place, + // so nothing but the offset goes with it, and nothing is painted in between - and the reading + // is normalized by the scroll offset, which is what makes it the same number at every scroll + // position and lets it be taken while the element is already pinned. + private static flowPosition(element: HTMLElement, scope: HTMLElement | Window): { top: number, left: number } { + const position = element.style.position; + + element.style.position = 'static'; + + const rect = element.getBoundingClientRect(); + const origin = Stickies.scopeOrigin(scope); + + // The property is put back rather than cleared: the inline style of the element may carry + // a position of the page's own, and this one is only meant to last for the measurement. + element.style.position = position; + + return { + top: rect.top - origin.top + origin.scrollTop, + left: rect.left - origin.left + origin.scrollLeft + }; + } + + // The border box of the scrollport and how far its content is scrolled within it. The two + // together are the fixed frame of reference the flow position is measured in: the box itself + // does not move while its content scrolls, so adding the scroll offset back cancels the scroll + // out of every reading taken from it. + private static scopeOrigin(scope: HTMLElement | Window): { top: number, left: number, scrollTop: number, scrollLeft: number } { + if (scope === window) { + return { top: 0, left: 0, scrollTop: window.scrollY, scrollLeft: window.scrollX }; + } + + const box = scope as HTMLElement; + const rect = box.getBoundingClientRect(); - return false; + return { top: rect.top, left: rect.left, scrollTop: box.scrollTop, scrollLeft: box.scrollLeft }; } // The scrollport the element is pinned within: its nearest ancestor that is a scroll diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor index 70d095d901b..95a55666d10 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor @@ -2,15 +2,15 @@ + Description="A wrapper over the browser's own position:sticky that pins its content to any edge of its scrolling container, with offsets, RTL-aware horizontal sticking, a semantic root tag, and a derived stuck-state event that names the edge." />
@@ -420,8 +420,14 @@ OnStuckChanged reports every flip of the state, the IsStuck property holds the current value, and StuckClass / StuckStyle are applied to the root element only while it is stuck (alongside the bit-stk-stc class) - which is what a bar that - casts a shadow only once content passes underneath it needs. The detection is attached only - when one of these three is used, so a sticky that does not ask for it stays pure CSS. + casts a shadow only once content passes underneath it needs. Pinned means carried away from + where the flow would have put it, so a bar that has never moved is not reported as stuck + however exactly it happens to rest on the edge: the shadow below appears on the first pixel of + scrolling, not before it. The detection is attached only when one of these is used, so a + sticky that does not ask for it stays pure CSS. It settles itself on every scroll and on every resize + of the element, its parent, the container or the page, and RefreshAsync is there for the layout + change none of those can see - content moved around inside the container leaving every one of those + boxes the size it was.
Currently stuck: @isStuck

@@ -478,7 +484,100 @@
- + +
+ OnStuckChanged only says that the element is pinned, and a TopAndBottom sticky is pinned + nearly all the time - what it needs to know is to which edge. OnStuckEdgesChanged and the + StuckEdges property answer that with the BitStickyEdges flags, and the component carries + one class per edge holding it (bit-stk-stc-top, bit-stk-stc-btm, + bit-stk-stc-lft, bit-stk-stc-rgt), which is how the shadow below falls away + from the edge the bar is pinned against without a single line of C#. The edges are physical, the way the + browser resolves them, so a Start sticky reports Left in an LTR container and Right in an RTL one. +
+
Currently pinned to: @stuckEdges
+
+
+

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. +

+ + @(stuckEdges is BitStickyEdges.None ? "Travelling with the content" : $"Pinned to {stuckEdges}") + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, + for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. It whispers of the stories waiting to be told, of the thoughts yet to be + shaped into meaning, and the emotions ready to resonate with every reader. +

+
+
+ + +
+ A sticky element is usually one HTML already has a name for, and Element is what renders it under + that name instead of the default div: the header of a pane, the + footer that keeps a toolbar in reach, the nav of a table of contents, the + aside of a sidebar, or the th of a frozen table header. The tag is what tells + assistive technologies which of them it is, and it decides nothing about the stickiness - every other + parameter works exactly the same whichever one is rendered. The one thing a tag can ask of the markup + around it is the table below: a collapsed table lends its cells no borders of their own, which some + engines still refuse to pin, so a frozen header is laid out with + border-collapse: separate and a zero border-spacing. +
+
Try scrolling the containers to see the sticky components in action:
+


+
A header and a footer:
+
+ A sticky header element +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. + Imagine this text as the scaffolding of something remarkable, a foundation upon which connections and + inspirations will be built. Soon, these lines will transform into narratives that provoke thought, + spark emotion, and resonate with those who encounter them. +
+ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits + to awaken. These words are temporary, standing in place of ideas yet to come, a glimpse into the infinite + possibilities that lie ahead. Think of this text as a bridge, connecting the empty spaces of now with the + vibrant narratives of tomorrow. +

+ A sticky footer element +
+


+
A frozen table header:
+
+ + + + Name + Role + + + + @foreach (var row in tableRows) + { + + + + + } + +
@row.Item1@row.Item2
+
+
+ +
A sticky element keeps a z-index of 1, enough to pass over the plain flowing content it sticks above without covering the popups and overlays of the rest of the page. Where @@ -533,7 +632,7 @@
- +
IsEnabled is the switch that turns the stickiness itself off: a disabled sticky steps back into the normal flow and scrolls away with its content like any other element, and its @@ -567,7 +666,7 @@
- +
Style and Class reach the root element, which is both the box that sticks and the box the content sits in - so a background, a border or a shadow given here travels and @@ -613,7 +712,7 @@
- +
The Start and End positions follow the reading direction: in a right-to-left container, Start is the right edge and End is the left one, with nothing about the markup changing. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs index c776ca346f2..cfa56a01f37 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Sticky/BitStickyDemo.razor.cs @@ -4,6 +4,19 @@ public partial class BitStickyDemo { private bool isStuck; private bool isStickyEnabled = true; + private BitStickyEdges stuckEdges; + + private readonly (string, string)[] tableRows = + [ + ("Ada Lovelace", "Mathematician"), + ("Grace Hopper", "Rear Admiral"), + ("Alan Turing", "Cryptanalyst"), + ("Katherine Johnson", "Physicist"), + ("Barbara Liskov", "Computer Scientist"), + ("Donald Knuth", "Author"), + ("Edsger Dijkstra", "Computer Scientist"), + ("Margaret Hamilton", "Software Engineer"), + ]; private readonly List componentParameters = [ @@ -22,6 +35,13 @@ public partial class BitStickyDemo Description = "The content of the Sticky, it can be any custom tag or text." }, new() + { + Name = "Element", + Type = "string?", + DefaultValue = "null", + Description = "The custom html element used for the root node, which is a div by default - a header, a footer, a nav, an aside or a th is what names the sticky region for assistive technologies. A name that is not one a tag can have falls back to the default." + }, + new() { Name = "Left", Type = "string?", @@ -33,7 +53,16 @@ public partial class BitStickyDemo Name = "OnStuckChanged", Type = "EventCallback", DefaultValue = "", - Description = "Callback for when the stuck state changes: true while the element is pinned to an edge of its scrolling container. Using it (or StuckClass/StuckStyle) attaches the stuck detection." + Description = "Callback for when the stuck state changes: true while the element is pinned to an edge of its scrolling container. Using it (or OnStuckEdgesChanged, StuckClass or StuckStyle) attaches the stuck detection." + }, + new() + { + Name = "OnStuckEdgesChanged", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the set of edges the element is pinned to changes. Unlike OnStuckChanged it also reports the move from one edge of a pair to the other, which never flips the boolean.", + Href = "#sticky-edges-enum", + LinkType = LinkType.Link, }, new() { @@ -56,7 +85,7 @@ public partial class BitStickyDemo Name = "StuckClass", Type = "string?", DefaultValue = "null", - Description = "The CSS class applied to the root element only while the component is stuck - a shadow, an opaque background, a border once content passes underneath. The bit-stk-stc class accompanies it." + Description = "The CSS class applied to the root element only while the component is stuck - a shadow, an opaque background, a border once content passes underneath. The bit-stk-stc class and one naming each pinned edge accompany it." }, new() { @@ -77,7 +106,7 @@ public partial class BitStickyDemo Name = "ZIndex", Type = "int?", DefaultValue = "null", - Description = "The z-index of the root element. When not set, the component keeps a z-index of 1 - enough to stay above the plain flowing content it sticks over without covering popups and overlays." + Description = "The z-index of the root element. When not set, the component keeps a z-index of 1 - enough to stay above the plain flowing content it sticks over without covering popups and overlays. That default is also the --bit-stk-zin custom property, for setting it from a stylesheet." } ]; @@ -88,7 +117,23 @@ public partial class BitStickyDemo Name = "IsStuck", Type = "bool", DefaultValue = "false", - Description = "Whether the component is currently stuck to an edge of its scrolling container. Always false unless OnStuckChanged, StuckClass or StuckStyle is used, since those are what attach the stuck detection." + Description = "Whether the component is currently stuck to an edge of its scrolling container. Always false unless one of OnStuckChanged, OnStuckEdgesChanged, StuckClass or StuckStyle is used, since those are what attach the stuck detection." + }, + new() + { + Name = "StuckEdges", + Type = "BitStickyEdges", + DefaultValue = "BitStickyEdges.None", + Description = "The edges of the scrolling container the component is currently pinned to. This is IsStuck with the edges named, and it carries both of them while the element is pinned into a corner.", + Href = "#sticky-edges-enum", + LinkType = LinkType.Link, + }, + new() + { + Name = "RefreshAsync", + Type = "ValueTask", + DefaultValue = "", + Description = "Reads the stuck state again, along with everything it is derived from. The state settles itself on every scroll and on every resize of the element, its parent, the container or the page, so this is only for a layout change none of those can see - content moved around inside the container without any of those boxes changing size." } ]; @@ -138,6 +183,45 @@ public partial class BitStickyDemo Description = "Sticks to whichever horizontal edge the scroll carries it to, following the reading direction the way Start and End do." } ] + }, + new() + { + Id = "sticky-edges-enum", + Name = "BitStickyEdges", + Description = "The edges of the scrolling container a BitSticky is currently pinned to. These are the physical edges the way the browser resolves them, so a Start sticky reports Left in an LTR container and Right in an RTL one, and more than one of them is set while the element is pinned into a corner.", + Items = + [ + new() + { + Name = "None", + Value = "0", + Description = "The element is not pinned: it is travelling with the content of its scrolling container." + }, + new() + { + Name = "Top", + Value = "1", + Description = "The element is pinned to the top edge of its scrolling container." + }, + new() + { + Name = "Bottom", + Value = "2", + Description = "The element is pinned to the bottom edge of its scrolling container." + }, + new() + { + Name = "Left", + Value = "4", + Description = "The element is pinned to the left edge of its scrolling container." + }, + new() + { + Name = "Right", + Value = "8", + Description = "The element is pinned to the right edge of its scrolling container." + } + ] } ]; @@ -716,6 +800,127 @@ These placeholder words symbolize the beginning-a moment of possibility where cr border: 1px solid #777; } + /* The shadow falls away from whichever edge is holding the bar. */ + .edge-shadow.bit-stk-stc-top { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); + } + + .edge-shadow.bit-stk-stc-btm { + box-shadow: 0 -4px 8px rgba(0, 0, 0, 0.5); + } + + + +
Currently pinned to: @stuckEdges
+ +
+ +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. +

+ + stuckEdges = v""> + @(stuckEdges is BitStickyEdges.None ? ""Travelling with the content"" : $""Pinned to {stuckEdges}"") + + +

+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment + when possibilities are limitless, waiting for content to emerge. +

+
"; + private readonly string example7CsharpCode = @" +private BitStickyEdges stuckEdges;"; + + private readonly string example8RazorCode = @" + + + +
+ + A sticky header element + +

+ Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + These placeholder words symbolize the beginning-a moment of possibility where creativity has yet to take shape. +

+ + A sticky footer element +
+ + +
+ + + + Name + Role + + + + @foreach (var row in tableRows) + { + + + + + } + +
@row.Item1@row.Item2
+
"; + + private readonly string example9RazorCode = @" +