From 68d05713c1b6bfcbfe93cedebf072801ac04672d Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 06:14:24 +0330 Subject: [PATCH 1/4] apply BitPullToRefresh improvements #13106 --- .../PullToRefresh/BitPullToRefresh.razor | 23 +- .../PullToRefresh/BitPullToRefresh.razor.cs | 173 +++++- .../PullToRefresh/BitPullToRefresh.scss | 25 +- .../PullToRefresh/BitPullToRefresh.ts | 194 ++++-- .../BitPullToRefreshClassStyles.cs | 20 + .../BitPullToRefreshJsRuntimeExtensions.cs | 23 +- .../PullToRefresh/BitPullToRefreshDemo.razor | 134 +++- .../BitPullToRefreshDemo.razor.cs | 161 ++++- .../BitPullToRefreshDemo.razor.samples.cs | 312 +++++++++- .../BitPullToRefreshDemo.razor.scss | 4 + .../PullToRefresh/BitPullToRefreshTests.cs | 572 +++++++++++++++++- 11 files changed, 1533 insertions(+), 108 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor index 308120e6fd1..fb4ea540212 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor @@ -5,18 +5,37 @@ id="@_Id" style="@StyleBuilder.Value" class="@ClassBuilder.Value" - dir="@Dir?.ToString().ToLower()"> + dir="@Dir?.ToString().ToLower()" + aria-label="@AriaLabel"> @(Anchor ?? ChildContent)
+ @* The one thing on the component a screen reader reads. It is on the page from the first render rather + than being inserted with its text already in it - a live region only announces what changes inside + it - so the swap to the refreshing text is what gets announced. *@ + @(_refreshing ? RefreshingLabel : _completed ? CompleteLabel : string.Empty)
- @if (Loading is not null) + @if (_completed) + { + if (Complete is not null) + { + @Complete + } + else + { + + + + } + } + else if (Loading is not null) { @Loading } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs index dc1f2c25bf5..78fa1dd3f6a 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs @@ -8,7 +8,13 @@ namespace Bit.BlazorUI; public partial class BitPullToRefresh : BitComponentBase { private decimal _diff; + private bool _completed; private bool _refreshing; + private int _lastTrigger; + private int _lastMargin; + private int _lastThreshold; + private decimal _lastFactor; + private bool _lastIsEnabled; private ElementReference _loadingRef = default!; @@ -29,7 +35,22 @@ public partial class BitPullToRefresh : BitComponentBase [Parameter] public BitPullToRefreshClassStyles? Classes { get; set; } /// - /// The factor to balance the pull height out. + /// The custom template to replace the default checkmark svg shown while the complete state is visible. + /// + [Parameter] public RenderFragment? Complete { get; set; } + + /// + /// The duration in milliseconds to keep the complete indicator visible after a successful refresh before snapping back (0 disables the complete state). + /// + [Parameter] public int CompleteDelay { get; set; } + + /// + /// The text that gets announced to screen readers while the complete state is visible after a successful refresh. + /// + [Parameter] public string CompleteLabel { get; set; } = "Refresh complete"; + + /// + /// The factor to balance the pull height out. The pull-down distance gets divided by it, so higher values make the pull feel heavier. /// [Parameter] public decimal Factor { get; set; } = 1.5m; @@ -63,6 +84,16 @@ public partial class BitPullToRefresh : BitComponentBase /// [Parameter] public EventCallback OnPullEnd { get; set; } + /// + /// The callback for when the pull-down gets canceled before release, providing the last pull height. + /// + [Parameter] public EventCallback OnPullCancel { get; set; } + + /// + /// The text that gets announced to screen readers while the refresh is in progress. + /// + [Parameter] public string RefreshingLabel { get; set; } = "Refreshing"; + /// /// The element that is the scroller in the anchor to control the behavior of the pull to refresh. /// @@ -79,7 +110,7 @@ public partial class BitPullToRefresh : BitComponentBase [Parameter] public BitPullToRefreshClassStyles? Styles { get; set; } /// - /// The threshold in pixel for pulling height that starts the pull to refresh process. + /// The dead-zone distance in pixel that the pull-down must travel before the pull to refresh process starts and the indicator appears. /// [Parameter] public int Threshold { get; set; } = 0; @@ -94,15 +125,44 @@ public partial class BitPullToRefresh : BitComponentBase + /// + /// Starts the refresh process programmatically, showing the loading indicator and invoking the OnRefresh callback. + /// It has no effect while the component is disabled, a refresh is already in progress or the complete state is visible. + /// + public async Task RefreshAsync() + { + if (_refreshing || _completed || IsEnabled is false || IsRendered is false || IsDisposed) return; + + await _js.BitPullToRefreshRefresh(UniqueId); + } + + + [JSInvokable("Refresh")] public async Task _Refresh() { + _diff = Trigger; _refreshing = true; await InvokeAsync(StateHasChanged); - await OnRefresh.InvokeAsync(); - _diff = 0; - _refreshing = false; - await InvokeAsync(StateHasChanged); + try + { + await OnRefresh.InvokeAsync(); + + if (CompleteDelay > 0) + { + _completed = true; + _refreshing = false; + await InvokeAsync(StateHasChanged); + await Task.Delay(CompleteDelay); + } + } + finally + { + _diff = 0; + _completed = false; + _refreshing = false; + await InvokeAsync(StateHasChanged); + } } [JSInvokable("OnStart")] @@ -123,9 +183,23 @@ public async Task _OnMove(decimal diff) [JSInvokable("OnEnd")] public async Task _OnEnd(decimal diff) { + if (diff < Trigger) + { + _diff = 0; + await InvokeAsync(StateHasChanged); + } + await OnPullEnd.InvokeAsync(diff); } + [JSInvokable("OnCancel")] + public async Task _OnCancel(decimal diff) + { + _diff = 0; + await InvokeAsync(StateHasChanged); + await OnPullCancel.InvokeAsync(diff); + } + protected override string RootElementClass => "bit-ptr"; @@ -140,21 +214,56 @@ protected override void RegisterCssStyles() StyleBuilder.Register(() => Styles?.Root); } + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (IsRendered is false) return; + + if (_lastTrigger != Trigger || _lastFactor != Factor || _lastMargin != Margin || _lastThreshold != Threshold || _lastIsEnabled != IsEnabled) + { + CacheJsParameters(); + await _js.BitPullToRefreshUpdate(UniqueId, Trigger, Factor, Margin, Threshold, IsEnabled); + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { + CacheJsParameters(); var dotnetObj = DotNetObjectReference.Create(this); - await _js.BitPullToRefreshSetup(UniqueId, RootElement, _loadingRef, ScrollerElement, ScrollerSelector, Trigger, Factor, Margin, Threshold, dotnetObj); + await _js.BitPullToRefreshSetup(UniqueId, RootElement, _loadingRef, ScrollerElement, ScrollerSelector, Trigger, Factor, Margin, Threshold, IsEnabled, dotnetObj); } await base.OnAfterRenderAsync(firstRender); } + private void CacheJsParameters() + { + _lastTrigger = Trigger; + _lastFactor = Factor; + _lastMargin = Margin; + _lastThreshold = Threshold; + _lastIsEnabled = IsEnabled; + } + + private bool CanRelease => _refreshing is false && _completed is false && _diff > 0 && _diff >= Trigger; + private string? GetSpinnerWrapperCssClasses() { List classes = ["bit-ptr-spw"]; + if (CanRelease) + { + classes.Add("bit-ptr-crl"); + + if (Classes?.SpinnerWrapperCanRelease?.HasValue() ?? false) + { + classes.Add(Classes.SpinnerWrapperCanRelease.Trim()); + } + } + if (_refreshing) { classes.Add("bit-ptr-swr"); @@ -165,6 +274,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + if (_completed) + { + classes.Add("bit-ptr-cmp"); + + if (Classes?.SpinnerWrapperComplete?.HasValue() ?? false) + { + classes.Add(Classes.SpinnerWrapperComplete.Trim()); + } + } + if (Classes?.SpinnerWrapper?.HasValue() ?? false) { classes.Add(Classes.SpinnerWrapper.Trim()); @@ -176,20 +295,31 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private string? GetSpinnerWrapperCssStyles() { List styles = []; - decimal size = 35 * _diff / Trigger; + var trigger = Trigger < 1 ? 1 : Trigger; + decimal size = 35 * _diff / trigger; - styles.Add(FormattableString.Invariant($"margin-top:{(_refreshing ? 0 : _diff / 2)}px;width:{size}px;height:{size}px")); + styles.Add(FormattableString.Invariant($"margin-top:{(_refreshing || _completed ? 0 : _diff / 2)}px;width:{size}px;height:{size}px")); if (Styles?.SpinnerWrapper?.HasValue() ?? false) { styles.Add(Styles.SpinnerWrapper.Trim(';')); } + if (CanRelease && (Styles?.SpinnerWrapperCanRelease?.HasValue() ?? false)) + { + styles.Add(Styles.SpinnerWrapperCanRelease.Trim(';')); + } + if (_refreshing && (Styles?.SpinnerWrapperRefreshing?.HasValue() ?? false)) { styles.Add(Styles.SpinnerWrapperRefreshing.Trim(';')); } + if (_completed && (Styles?.SpinnerWrapperComplete?.HasValue() ?? false)) + { + styles.Add(Styles.SpinnerWrapperComplete.Trim(';')); + } + return string.Join(';', styles); } @@ -202,6 +332,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) classes.Add(Classes.Spinner.Trim()); } + if (CanRelease && (Classes?.SpinnerCanRelease?.HasValue() ?? false)) + { + classes.Add(Classes.SpinnerCanRelease.Trim()); + } + if (_refreshing) { classes.Add("bit-ptr-spin"); @@ -212,26 +347,42 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + if (_completed && (Classes?.SpinnerComplete?.HasValue() ?? false)) + { + classes.Add(Classes.SpinnerComplete.Trim()); + } + return string.Join(' ', classes).Trim(); } private string? GetSpinnerCssStyles() { List styles = []; - decimal size = 24 * _diff / Trigger; + var trigger = Trigger < 1 ? 1 : Trigger; + decimal size = 24 * _diff / trigger; - styles.Add(FormattableString.Invariant($"transform:rotate({(_diff - Trigger) * 2}deg);width:{size}px;height:{size}px")); + styles.Add(FormattableString.Invariant($"transform:rotate({(_diff - trigger) * 2}deg);width:{size}px;height:{size}px")); if (Styles?.Spinner?.HasValue() ?? false) { styles.Add(Styles.Spinner.Trim(';')); } + if (CanRelease && (Styles?.SpinnerCanRelease?.HasValue() ?? false)) + { + styles.Add(Styles.SpinnerCanRelease.Trim(';')); + } + if (_refreshing && (Styles?.SpinnerRefreshing?.HasValue() ?? false)) { styles.Add(Styles.SpinnerRefreshing.Trim(';')); } + if (_completed && (Styles?.SpinnerComplete?.HasValue() ?? false)) + { + styles.Add(Styles.SpinnerComplete.Trim(';')); + } + return string.Join(';', styles); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss index 0f50e946f6c..97c042aa00f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss @@ -17,13 +17,17 @@ justify-content: center; } +.bit-ptr-rtn { + transition: min-height $mot-duration-short $mot-easing; +} + .bit-ptr-spw { display: flex; border-radius: 50%; align-items: center; justify-content: center; background-color: $clr-bg-pri; - box-shadow: $box-shadow-callout2; + box-shadow: $box-shadow-popup; } .bit-ptr-swr { @@ -41,3 +45,22 @@ .bit-ptr-spin { animation: bit-spin $mot-duration-spinner $mot-easing-spinner infinite; } + +.bit-ptr-cmp { + background-color: $clr-bg-ter; +} + +// What the refresh says to a screen reader: read by assistive technologies, taken out of the layout for +// everyone else. It stays in the render tree rather than being display:none, which would take it out of the +// accessibility tree along with the layout and leave the live region with nothing to announce. +.bit-ptr-vhd { + border: 0; + padding: 0; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + clip-path: inset(50%); +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts index 5391495c60b..617cf8e4d08 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts @@ -12,112 +12,161 @@ factor: number, margin: number, threshold: number, + enabled: boolean, dotnetObj: DotNetObject) { const anchorEl = anchor ?? document.body as HTMLElement; - const scrollerEl = scrollerElement ?? ((scrollerSelector && document.querySelector(scrollerSelector)) ?? (!!anchor ? anchor.children[0] : anchorEl)) as HTMLElement; + const scrollerEl = scrollerElement ?? ((scrollerSelector && (anchorEl.querySelector(scrollerSelector) ?? document.querySelector(scrollerSelector))) ?? (!!anchor ? anchor.children[0] : anchorEl)) as HTMLElement; - let diff = 0; - let startY = -1; - let refreshing = false; + const options: BitPullToRefreshOptions = { trigger, factor, margin, threshold, enabled }; + const state: BitPullToRefreshState = { diff: 0, startY: -1, refreshing: false }; const isTouchDevice = Utils.isTouchDevice(); const getY = (e: TouchEvent | PointerEvent) => isTouchDevice ? (e as TouchEvent).touches[0].screenY : (e as PointerEvent).screenY; const onScroll = () => { - anchorEl.style.touchAction = scrollerEl.scrollTop === 0 ? 'pan-x pan-down pinch-zoom' : ""; + anchorEl.style.touchAction = (options.enabled && scrollerEl.scrollTop === 0) ? 'pan-x pan-down pinch-zoom' : ''; + scrollerEl.style.overscrollBehaviorY = options.enabled ? 'contain' : ''; }; const onStart = async (e: TouchEvent | PointerEvent): Promise => { - if (scrollerEl.scrollTop !== 0 || refreshing) { - startY = -1; + if (!isTouchDevice && (e as PointerEvent).button !== 0) return; + + if (!options.enabled || state.refreshing || scrollerEl.scrollTop !== 0) { + state.startY = -1; return; } - startY = getY(e); + state.startY = getY(e); + loadingEl.classList.remove('bit-ptr-rtn'); const bcr = anchorEl.getBoundingClientRect(); loadingEl.style.width = `${bcr.width}px`; await dotnetObj.invokeMethodAsync('OnStart', bcr.top, bcr.left, bcr.width); }; const onMove = async (e: TouchEvent | PointerEvent): Promise => { - if (startY === -1 || refreshing) return; + if (state.startY === -1 || state.refreshing) return; if (scrollerEl.scrollTop !== 0) { - startY = -1; + state.startY = -1; return; } - diff = getY(e) - startY; + let diff = getY(e) - state.startY; if (diff < 0) { - startY = -1; + state.startY = -1; return; } - if (diff > threshold && e.cancelable) { + if (diff <= options.threshold) return; + + if (e.cancelable) { e.preventDefault(); e.stopPropagation(); } - diff = diff / factor; - diff = diff > trigger ? trigger : diff; - loadingEl.style.minHeight = `${diff * factor + margin}px`; + diff = (diff - options.threshold) / options.factor; + diff = diff > options.trigger ? options.trigger : diff; + state.diff = diff; + loadingEl.style.minHeight = `${diff * options.factor + options.margin}px`; await dotnetObj.invokeMethodAsync('OnMove', diff); }; const onEnd = async (e: TouchEvent | PointerEvent): Promise => { - if (startY === -1 || refreshing) return; - startY = -1; + if (state.startY === -1 || state.refreshing) return; + state.startY = -1; try { - await dotnetObj.invokeMethodAsync('OnEnd', diff); + await dotnetObj.invokeMethodAsync('OnEnd', state.diff); - if (diff >= trigger) { - refreshing = true; + if (state.diff >= options.trigger) { + state.refreshing = true; await dotnetObj.invokeMethodAsync('Refresh'); } } finally { - diff = 0; - refreshing = false; - loadingEl.style.minHeight = '0'; + state.diff = 0; + state.refreshing = false; + PullToRefresh.snapBack(loadingEl); } }; - const onLeave = (e: PointerEvent) => { - if (startY === -1) return; - loadingEl.style.minHeight = '0'; - startY = -1; - } + const onCancel = async (e: TouchEvent | PointerEvent): Promise => { + if (state.startY === -1 || state.refreshing) return; + state.startY = -1; + + const diff = state.diff; + state.diff = 0; + PullToRefresh.snapBack(loadingEl); + + await dotnetObj.invokeMethodAsync('OnCancel', diff); + }; if (isTouchDevice) { anchorEl.addEventListener('touchstart', onStart); - anchorEl.addEventListener('touchmove', onMove); + anchorEl.addEventListener('touchmove', onMove, { passive: false }); anchorEl.addEventListener('touchend', onEnd); + anchorEl.addEventListener('touchcancel', onCancel); } else { anchorEl.addEventListener('pointerdown', onStart); anchorEl.addEventListener('pointermove', onMove); anchorEl.addEventListener('pointerup', onEnd); - anchorEl.addEventListener('pointerleave', onLeave, false); - //anchorEl.addEventListener('pointerout', onOut, false); + anchorEl.addEventListener('pointerleave', onCancel, false); + anchorEl.addEventListener('pointercancel', onCancel); } scrollerEl.addEventListener('scroll', onScroll); onScroll(); - const refresher = new BitPullRefresher(id, anchor, loadingEl, scrollerElement, scrollerSelector, trigger, factor, margin, threshold, dotnetObj); + const refresher = new BitPullRefresher(id, anchorEl, loadingEl, options, state, dotnetObj, onScroll); refresher.setDisposer(() => { if (isTouchDevice) { anchorEl.removeEventListener('touchstart', onStart); anchorEl.removeEventListener('touchmove', onMove); anchorEl.removeEventListener('touchend', onEnd); + anchorEl.removeEventListener('touchcancel', onCancel); } else { anchorEl.removeEventListener('pointerdown', onStart); anchorEl.removeEventListener('pointermove', onMove); anchorEl.removeEventListener('pointerup', onEnd); - anchorEl.removeEventListener('pointerleave', onLeave, false); - //anchorEl.removeEventListener('pointerout', onOut, false); + anchorEl.removeEventListener('pointerleave', onCancel, false); + anchorEl.removeEventListener('pointercancel', onCancel); } scrollerEl.removeEventListener('scroll', onScroll); + anchorEl.style.touchAction = ''; + scrollerEl.style.overscrollBehaviorY = ''; + loadingEl.style.minHeight = ''; }); PullToRefresh._refreshers.push(refresher); } + public static update( + id: string, + trigger: number, + factor: number, + margin: number, + threshold: number, + enabled: boolean) { + const refresher = PullToRefresh._refreshers.find(r => r.id === id); + if (!refresher) return; + + refresher.options.trigger = trigger; + refresher.options.factor = factor; + refresher.options.margin = margin; + refresher.options.threshold = threshold; + refresher.options.enabled = enabled; + + if (!enabled && !refresher.state.refreshing) { + refresher.state.diff = 0; + refresher.state.startY = -1; + PullToRefresh.snapBack(refresher.loadingEl); + } + + refresher.syncTouchAction(); + } + + public static async refresh(id: string) { + const refresher = PullToRefresh._refreshers.find(r => r.id === id); + if (!refresher) return; + + await refresher.refresh(); + } + public static dispose(id: string) { const refresher = PullToRefresh._refreshers.find(r => r.id === id); if (!refresher) return; @@ -126,42 +175,73 @@ refresher.dispose(); } + private static snapBack(loadingEl: HTMLElement) { + loadingEl.classList.add('bit-ptr-rtn'); + void loadingEl.offsetHeight; + loadingEl.style.minHeight = '0'; + } } - class BitPullRefresher { - id: string; - anchor: HTMLElement | undefined; - loadingEl: HTMLElement; - scrollerElement: HTMLElement | undefined; - scrollerSelector: string | undefined; + interface BitPullToRefreshOptions { trigger: number; factor: number; margin: number; threshold: number; + enabled: boolean; + } + + interface BitPullToRefreshState { + diff: number; + startY: number; + refreshing: boolean; + } + + class BitPullRefresher { + id: string; + anchorEl: HTMLElement; + loadingEl: HTMLElement; + options: BitPullToRefreshOptions; + state: BitPullToRefreshState; dotnetObj: DotNetObject; + syncTouchAction: () => void; disposer: () => void = () => { }; constructor(id: string, - anchor: HTMLElement | undefined, + anchorEl: HTMLElement, loadingEl: HTMLElement, - scrollerElement: HTMLElement | undefined, - scrollerSelector: string | undefined, - trigger: number, - factor: number, - margin: number, - threshold: number, - dotnetObj: DotNetObject) { + options: BitPullToRefreshOptions, + state: BitPullToRefreshState, + dotnetObj: DotNetObject, + syncTouchAction: () => void) { this.id = id; - this.anchor = anchor; + this.anchorEl = anchorEl; this.loadingEl = loadingEl; - this.scrollerElement = scrollerElement; - this.scrollerSelector = scrollerSelector; - this.trigger = trigger; - this.factor = factor; - this.margin = margin; - this.threshold = threshold; + this.options = options; + this.state = state; this.dotnetObj = dotnetObj; + this.syncTouchAction = syncTouchAction; } + + public async refresh() { + if (!this.options.enabled || this.state.refreshing) return; + this.state.refreshing = true; + this.state.diff = 0; + this.state.startY = -1; + + try { + const bcr = this.anchorEl.getBoundingClientRect(); + this.loadingEl.style.width = `${bcr.width}px`; + this.loadingEl.classList.add('bit-ptr-rtn'); + void this.loadingEl.offsetHeight; + this.loadingEl.style.minHeight = `${this.options.trigger * this.options.factor + this.options.margin}px`; + + await this.dotnetObj.invokeMethodAsync('Refresh'); + } finally { + this.state.refreshing = false; + this.loadingEl.style.minHeight = '0'; + } + } + public setDisposer(disposer: () => void) { this.disposer = disposer; } @@ -171,4 +251,4 @@ this.dotnetObj?.dispose(); } } -} \ No newline at end of file +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshClassStyles.cs index dafe3fc1c38..622316994a1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshClassStyles.cs @@ -17,18 +17,38 @@ public class BitPullToRefreshClassStyles /// public string? SpinnerWrapper { get; set; } + /// + /// Custom CSS classes/styles for the spinner wrapper element when the pull passed the trigger and releasing starts the refresh. + /// + public string? SpinnerWrapperCanRelease { get; set; } + /// /// Custom CSS classes/styles for the spinner wrapper element in refreshing mode. /// public string? SpinnerWrapperRefreshing { get; set; } + /// + /// Custom CSS classes/styles for the spinner wrapper element while the complete state is visible after a successful refresh. + /// + public string? SpinnerWrapperComplete { get; set; } + /// /// Custom CSS classes/styles for the spinner element. /// public string? Spinner { get; set; } + /// + /// Custom CSS classes/styles for the spinner element when the pull passed the trigger and releasing starts the refresh. + /// + public string? SpinnerCanRelease { get; set; } + /// /// Custom CSS classes/styles for the spinner element in refreshing mode. /// public string? SpinnerRefreshing { get; set; } + + /// + /// Custom CSS classes/styles for the spinner element while the complete state is visible after a successful refresh. + /// + public string? SpinnerComplete { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs index bdc8410354d..a714a3d8f0e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs @@ -1,6 +1,4 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; internal static class BitPullToRefreshJsRuntimeExtensions { @@ -14,9 +12,26 @@ internal static ValueTask BitPullToRefreshSetup(this IJSRuntime jsRuntime, decimal factor, int margin, int threshold, + bool enabled, DotNetObjectReference? dotnetObjectReference) { - return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.setup", id, anchor, loading, scrollerElement, scrollerSelector, trigger, factor, margin , threshold, dotnetObjectReference); + return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.setup", id, anchor, loading, scrollerElement, scrollerSelector, trigger, factor, margin, threshold, enabled, dotnetObjectReference); + } + + internal static ValueTask BitPullToRefreshUpdate(this IJSRuntime jsRuntime, + string id, + int trigger, + decimal factor, + int margin, + int threshold, + bool enabled) + { + return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.update", id, trigger, factor, margin, threshold, enabled); + } + + internal static ValueTask BitPullToRefreshRefresh(this IJSRuntime jsRuntime, string id) + { + return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.refresh", id); } internal static ValueTask BitPullToRefreshDispose(this IJSRuntime jsRuntime, string id) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor index fe2008f4c52..790b5579bf1 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor @@ -2,15 +2,18 @@ + Description="The PullToRefresh component adds the pull down to refresh gesture to a page or any scrollable element, supporting both touch and mouse input with a customizable trigger distance, loading indicator and an optional success state." /> +
Wrap any scrollable element in a BitPullToRefresh and handle the OnRefresh event. Drag the content down from its top (by touch or mouse) and release it past the trigger distance to start the refresh; the indicator stays visible until the OnRefresh callback completes.
+
@foreach (var (idx, i) in basicItems) @@ -22,6 +25,8 @@ +
Replace the default spinner with any custom content using the Loading template; it scales and rotates with the pull progress just like the default one.
+
@@ -43,6 +48,8 @@ +
Multiple BitPullToRefresh instances work independently on the same page, each with its own anchor, state and OnRefresh handler.
+
@@ -69,7 +76,7 @@ -
An illustrative example of integrating this component into a straightforward mobile application.
+
An illustrative example of integrating this component into a straightforward mobile application. The ScrollerSelector parameter points at the actual scrollable element inside the anchor, so the pull gesture only engages when that scroller sits at its very top.

@@ -99,12 +106,123 @@
- -
Empower customization by overriding default styles and classes, allowing tailored design modifications to suit specific UI requirements.
+ +
Setting IsEnabled to false turns the pull gesture off entirely while leaving the anchor content fully interactive; flipping it back on re-enables the gesture right away.
+
+ +
+ +
+ @foreach (var (idx, i) in disabledItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
+ + +
Fine-tune the pull behavior: Trigger is the pull height that starts the refresh, Factor damps the finger movement (higher values make the pull feel heavier), Margin adds extra space above the indicator, and Threshold is a dead zone the pull must travel before the indicator appears. Changes to these parameters apply immediately, even after the component has rendered.
+
+
+ +
+ @foreach (var (idx, i) in behaviorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
+ +
+ +
+ +
+ +
+
+
+ + +
Start a refresh from code by calling the RefreshAsync method on a component reference; it opens the loading indicator, runs the OnRefresh callback and closes the indicator when the callback completes, exactly like a pull gesture would.
+
+ Refresh +

+ +
+ @foreach (var (idx, i) in programmaticItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
+ + +
The component reports every stage of the gesture: OnPullStart provides the anchor's position and width when the pull begins, OnPullMove streams the current pull height, OnPullEnd fires on release with the final height, OnPullCancel fires when the gesture gets canceled before release, and OnRefresh runs when the pull gets released at the trigger height.
+
+
+ +
+ @foreach (var (idx, i) in eventsItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
+
PullStart: @(pullStartArgs is null ? "-" : $"top:{pullStartArgs.Top:F0}, left:{pullStartArgs.Left:F0}, width:{pullStartArgs.Width:F0}")
+
PullMove diff: @pullMoveDiff.ToString("F1")
+
PullEnd diff: @pullEndDiff.ToString("F1")
+
PullCancel diff: @pullCancelDiff.ToString("F1")
+
Refresh count: @refreshCount
+
+
+
+ + +
Set CompleteDelay to a positive number of milliseconds to keep a brief success indicator visible after the refresh finishes, before the loading area snaps back; by default it shows a checkmark, and the Complete template replaces it with any custom content, like the emoji in the second instance below.
+
+
+ +
+ @foreach (var (idx, i) in completeItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + + +
+ @foreach (var (idx, i) in completeCustomItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ 🎉 +
+
+
+ + +
Empower customization by overriding default styles and classes, allowing tailored design modifications to suit specific UI requirements. The Styles and Classes parameters target each part of the component, including the state-specific SpinnerCanRelease and SpinnerRefreshing entries; pull far enough to see the spinner change once releasing would start the refresh.


+ Styles="@(new() { Loading = "background-color: rgb(76, 255, 0, 0.1)", Spinner = "padding: 5px;border-radius: 50%;background-color: #4cff00;", SpinnerCanRelease = "background-color: #ffd800;" })">
@foreach (var (idx, i) in styleItems) { @@ -114,7 +232,7 @@ + Classes="@(new() { Loading = "custom-loading", Spinner = "custom-spinner", SpinnerCanRelease = "custom-can-release" })">
@foreach (var (idx, i) in classItems) { @@ -125,4 +243,4 @@
- \ No newline at end of file + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs index 76641be9a9a..db7534684a5 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs @@ -28,11 +28,32 @@ public partial class BitPullToRefreshDemo Href = "#class-styles", }, new() + { + Name = "Complete", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to replace the default checkmark svg shown while the complete state is visible.", + }, + new() + { + Name = "CompleteDelay", + Type = "int", + DefaultValue = "0", + Description = "The duration in milliseconds to keep the complete indicator visible after a successful refresh before snapping back (0 disables the complete state).", + }, + new() + { + Name = "CompleteLabel", + Type = "string", + DefaultValue = "Refresh complete", + Description = "The text that gets announced to screen readers while the complete state is visible after a successful refresh.", + }, + new() { Name = "Factor", Type = "decimal", - DefaultValue = "2", - Description = "The factor to balance the pull height out.", + DefaultValue = "1.5", + Description = "The factor to balance the pull height out. The pull-down distance gets divided by it, so higher values make the pull feel heavier.", }, new() { @@ -53,7 +74,7 @@ public partial class BitPullToRefreshDemo Name = "OnRefresh", Type = "EventCallback", DefaultValue = "", - Description = "The callback for when the threshold of the pull-down happens.", + Description = "The callback for when the trigger condition of the pull-down happens.", }, new() { @@ -79,6 +100,20 @@ public partial class BitPullToRefreshDemo Description = "The callback for the ending of the pull-down.", }, new() + { + Name = "OnPullCancel", + Type = "EventCallback", + DefaultValue = "", + Description = "The callback for when the pull-down gets canceled before release, providing the last pull height.", + }, + new() + { + Name = "RefreshingLabel", + Type = "string", + DefaultValue = "Refreshing", + Description = "The text that gets announced to screen readers while the refresh is in progress.", + }, + new() { Name = "ScrollerElement", Type = "ElementReference?", @@ -106,7 +141,7 @@ public partial class BitPullToRefreshDemo Name = "Threshold", Type = "int", DefaultValue = "0", - Description = "The threshold in pixel for pulling height that starts the pull to refresh process.", + Description = "The dead-zone distance in pixel that the pull-down must travel before the pull to refresh process starts and the indicator appears.", }, new() { @@ -117,6 +152,16 @@ public partial class BitPullToRefreshDemo } ]; + private readonly List componentPublicMembers = + [ + new() + { + Name = "RefreshAsync", + Type = "Task", + Description = "Starts the refresh process programmatically, showing the loading indicator and invoking the OnRefresh callback. It has no effect while the component is disabled, a refresh is already in progress or the complete state is visible.", + }, + ]; + private readonly List componentSubClasses = [ new() @@ -173,6 +218,13 @@ public partial class BitPullToRefreshDemo Description = "Custom CSS classes/styles for the spinner wrapper element." }, new() + { + Name = "SpinnerWrapperCanRelease", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the spinner wrapper element when the pull passed the trigger and releasing starts the refresh." + }, + new() { Name = "SpinnerWrapperRefreshing", Type = "string?", @@ -180,6 +232,13 @@ public partial class BitPullToRefreshDemo Description = "Custom CSS classes/styles for the spinner wrapper element in refreshing mode." }, new() + { + Name = "SpinnerWrapperComplete", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the spinner wrapper element while the complete state is visible after a successful refresh." + }, + new() { Name = "Spinner", Type = "string?", @@ -187,12 +246,26 @@ public partial class BitPullToRefreshDemo Description = "Custom CSS classes/styles for the spinner element." }, new() + { + Name = "SpinnerCanRelease", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the spinner element when the pull passed the trigger and releasing starts the refresh." + }, + new() { Name = "SpinnerRefreshing", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the spinner element in refreshing mode." }, + new() + { + Name = "SpinnerComplete", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the spinner element while the complete state is visible after a successful refresh." + }, ] } ]; @@ -239,6 +312,86 @@ private async Task HandleOnRefreshAdvanced() _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); } + private bool isEnabled = true; + private (int, int)[] disabledItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshDisabled() + { + await Task.Delay(2000); + disabledItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private double trigger = 80; + private double factor = 1.5; + private double margin = 30; + private double threshold = 0; + private (int, int)[] behaviorItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshBehavior() + { + await Task.Delay(2000); + behaviorItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private BitPullToRefresh pullToRefreshRef = default!; + private (int, int)[] programmaticItems = GenerateRandomNumbers(1, 51); + private async Task RefreshProgrammatically() + { + await pullToRefreshRef.RefreshAsync(); + } + private async Task HandleOnRefreshProgrammatic() + { + await Task.Delay(2000); + programmaticItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private int refreshCount; + private decimal pullMoveDiff; + private decimal pullEndDiff; + private decimal pullCancelDiff; + private BitPullToRefreshPullStartArgs? pullStartArgs; + private (int, int)[] eventsItems = GenerateRandomNumbers(1, 51); + private void HandleOnPullStart(BitPullToRefreshPullStartArgs args) + { + pullStartArgs = args; + } + private void HandleOnPullMove(decimal diff) + { + pullMoveDiff = diff; + } + private void HandleOnPullEnd(decimal diff) + { + pullEndDiff = diff; + } + private void HandleOnPullCancel(decimal diff) + { + pullCancelDiff = diff; + } + private async Task HandleOnRefreshEvents() + { + refreshCount++; + await Task.Delay(2000); + eventsItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private (int, int)[] completeItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshComplete() + { + await Task.Delay(2000); + completeItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private (int, int)[] completeCustomItems = GenerateRandomNumbers(51, 101); + private async Task HandleOnRefreshCompleteCustom() + { + await Task.Delay(2000); + completeCustomItems = GenerateRandomNumbers(51, 101); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + private (int, int)[] styleItems = GenerateRandomNumbers(1, 51); private async Task HandleOnRefreshStyle() { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs index 5781260c6d2..bbfe81b7b30 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs @@ -226,40 +226,312 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max) user-select: none; border: 1px gray solid; } - - .custom-loading { - background-color: rgb(255, 106, 0, 0.1); - } - - .custom-spinner { - padding: 5px; - border-radius: 50%; - background-color: #ff6a00; - } -
- + + +
- @foreach (var (idx, i) in styleItems) + @foreach (var (idx, i) in disabledItems) {
@(idx.ToString().PadLeft(2, '0')). Item @i
}
-
+
"; + private readonly string example5CsharpCode = @" +private bool isEnabled = true; +private (int, int)[] disabledItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshDisabled() +{ + await Task.Delay(2000); + disabledItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example6RazorCode = @" + + +
+ +
+ @foreach (var (idx, i) in behaviorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ +
+ +
+ +
+ +
+ +
+
"; + private readonly string example6CsharpCode = @" +private double trigger = 80; +private double factor = 1.5; +private double margin = 30; +private double threshold = 0; +private (int, int)[] behaviorItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshBehavior() +{ + await Task.Delay(2000); + behaviorItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example7RazorCode = @" + - +Refresh + +
- @foreach (var (idx, i) in classItems) + @foreach (var (idx, i) in programmaticItems) {
@(idx.ToString().PadLeft(2, '0')). Item @i
}
-
+
"; + private readonly string example7CsharpCode = @" +private BitPullToRefresh pullToRefreshRef = default!; +private (int, int)[] programmaticItems = GenerateRandomNumbers(1, 51); +private async Task RefreshProgrammatically() +{ + await pullToRefreshRef.RefreshAsync(); +} +private async Task HandleOnRefreshProgrammatic() +{ + await Task.Delay(2000); + programmaticItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example8RazorCode = @" + + +
+ +
+ @foreach (var (idx, i) in eventsItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ +
+
PullStart: @(pullStartArgs is null ? ""-"" : $""top:{pullStartArgs.Top:F0}, left:{pullStartArgs.Left:F0}, width:{pullStartArgs.Width:F0}"")
+
PullMove diff: @pullMoveDiff.ToString(""F1"")
+
PullEnd diff: @pullEndDiff.ToString(""F1"")
+
PullCancel diff: @pullCancelDiff.ToString(""F1"")
+
Refresh count: @refreshCount
+
"; - private readonly string example5CsharpCode = @" + private readonly string example8CsharpCode = @" +private int refreshCount; +private decimal pullMoveDiff; +private decimal pullEndDiff; +private decimal pullCancelDiff; +private BitPullToRefreshPullStartArgs? pullStartArgs; +private (int, int)[] eventsItems = GenerateRandomNumbers(1, 51); +private void HandleOnPullStart(BitPullToRefreshPullStartArgs args) +{ + pullStartArgs = args; +} +private void HandleOnPullMove(decimal diff) +{ + pullMoveDiff = diff; +} +private void HandleOnPullEnd(decimal diff) +{ + pullEndDiff = diff; +} +private void HandleOnPullCancel(decimal diff) +{ + pullCancelDiff = diff; +} +private async Task HandleOnRefreshEvents() +{ + refreshCount++; + await Task.Delay(2000); + eventsItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example9RazorCode = @" + + +
+ +
+ @foreach (var (idx, i) in completeItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + + +
+ @foreach (var (idx, i) in completeCustomItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ 🎉 +
+
"; + private readonly string example9CsharpCode = @" +private (int, int)[] completeItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshComplete() +{ + await Task.Delay(2000); + completeItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private (int, int)[] completeCustomItems = GenerateRandomNumbers(51, 101); +private async Task HandleOnRefreshCompleteCustom() +{ + await Task.Delay(2000); + completeCustomItems = GenerateRandomNumbers(51, 101); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example10RazorCode = @" + + +
+ +
+ @foreach (var (idx, i) in styleItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + +
+ @foreach (var (idx, i) in classItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
"; + private readonly string example10CsharpCode = @" private (int, int)[] styleItems = GenerateRandomNumbers(1, 51); private async Task HandleOnRefreshStyle() { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.scss index 133e1baa79a..a9cdcadc6c8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.scss @@ -49,6 +49,10 @@ background-color: #ff6a00; } + .custom-can-release { + background-color: #ffd800; + } + .row { color: black; padding: 10px; diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs index ac287b8d261..594dd0ce60c 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs @@ -1,4 +1,7 @@ -using Bunit; +using System; +using System.Linq; +using System.Threading.Tasks; +using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -24,6 +27,22 @@ public void BitPullToRefreshShouldRenderStructure() Assert.IsNotNull(loading); Assert.IsNotNull(spinnerWrapper); Assert.IsNotNull(spinner); + + Assert.AreEqual("status", loading.GetAttribute("role")); + } + + [TestMethod] + public void BitPullToRefreshShouldRenderAriaLabel() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AriaLabel, "pull down to refresh"); + }); + + var root = component.Find(".bit-ptr"); + Assert.AreEqual("pull down to refresh", root.GetAttribute("aria-label")); } [TestMethod] @@ -42,6 +61,67 @@ public void BitPullToRefreshShouldInvokeOnRefresh() Assert.IsTrue(refreshed); } + [TestMethod] + public void BitPullToRefreshShouldShowRefreshingStateDuringOnRefresh() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + var spinner = component.Find(".bit-ptr-spn"); + Assert.IsTrue(spinner.ClassList.Contains("bit-ptr-spin")); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("bit-ptr-swr")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "margin-top:0px"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:35px"); + StringAssert.Contains(spinner.GetAttribute("style"), "width:24px"); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + + spinner = component.Find(".bit-ptr-spn"); + Assert.IsFalse(spinner.ClassList.Contains("bit-ptr-spin")); + + spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-swr")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + + [TestMethod] + public async Task BitPullToRefreshShouldResetStateWhenOnRefreshThrows() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => throw new InvalidOperationException("refresh failed"))); + }); + + var thrown = false; + try + { + await component.Instance._Refresh(); + } + catch (InvalidOperationException) + { + thrown = true; + } + Assert.IsTrue(thrown); + + var spinner = component.Find(".bit-ptr-spn"); + Assert.IsFalse(spinner.ClassList.Contains("bit-ptr-spin")); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + [TestMethod] public void BitPullToRefreshShouldInvokePullCallbacks() { @@ -50,17 +130,20 @@ public void BitPullToRefreshShouldInvokePullCallbacks() BitPullToRefreshPullStartArgs? startArgs = null; decimal moveDiff = 0; decimal endDiff = 0; + decimal cancelDiff = 0; var component = RenderComponent(parameters => { parameters.Add(p => p.OnPullStart, EventCallback.Factory.Create(this, args => startArgs = args)); parameters.Add(p => p.OnPullMove, EventCallback.Factory.Create(this, diff => moveDiff = diff)); parameters.Add(p => p.OnPullEnd, EventCallback.Factory.Create(this, diff => endDiff = diff)); + parameters.Add(p => p.OnPullCancel, EventCallback.Factory.Create(this, diff => cancelDiff = diff)); }); component.Instance._OnStart(10m, 20m, 100m).GetAwaiter().GetResult(); component.Instance._OnMove(80m).GetAwaiter().GetResult(); component.Instance._OnEnd(60m).GetAwaiter().GetResult(); + component.Instance._OnCancel(40m).GetAwaiter().GetResult(); Assert.IsNotNull(startArgs); Assert.AreEqual(10m, startArgs!.Top); @@ -69,6 +152,151 @@ public void BitPullToRefreshShouldInvokePullCallbacks() Assert.AreEqual(80m, moveDiff); Assert.AreEqual(60m, endDiff); + Assert.AreEqual(40m, cancelDiff); + } + + [TestMethod] + public void BitPullToRefreshShouldSizeSpinnerBasedOnPullMove() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "margin-top:20px"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:17.5px"); + + var spinner = component.Find(".bit-ptr-spn"); + StringAssert.Contains(spinner.GetAttribute("style"), "width:12px"); + StringAssert.Contains(spinner.GetAttribute("style"), "rotate(-80deg)"); + } + + [TestMethod] + public void BitPullToRefreshShouldApplyCanReleaseStateAtTrigger() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var classes = new BitPullToRefreshClassStyles + { + SpinnerWrapperCanRelease = "custom-swc", + SpinnerCanRelease = "custom-spc" + }; + + var styles = new BitPullToRefreshClassStyles + { + SpinnerWrapperCanRelease = "border:2px solid gold;", + SpinnerCanRelease = "outline:2px solid gold;" + }; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Classes, classes); + parameters.Add(p => p.Styles, styles); + }); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-crl")); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("custom-swc")); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + + spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("bit-ptr-crl")); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("custom-swc")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "border:2px solid gold"); + + var spinner = component.Find(".bit-ptr-spn"); + Assert.IsTrue(spinner.ClassList.Contains("custom-spc")); + StringAssert.Contains(spinner.GetAttribute("style"), "outline:2px solid gold"); + + component.Instance._OnEnd(80m).GetAwaiter().GetResult(); + component.Instance._Refresh().GetAwaiter().GetResult(); + + spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-crl")); + } + + [TestMethod] + public void BitPullToRefreshShouldNotApplyCanReleaseStateWhileRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-crl")); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("bit-ptr-swr")); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + } + + [TestMethod] + public void BitPullToRefreshShouldResetSpinnerOnPullEndBelowTrigger() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + component.Instance._OnEnd(40m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + + [TestMethod] + public void BitPullToRefreshShouldKeepSpinnerOnPullEndAtTrigger() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + component.Instance._OnEnd(80m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:35px"); + } + + [TestMethod] + public void BitPullToRefreshShouldResetSpinnerOnCancel() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + component.Instance._OnCancel(40m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + + [TestMethod] + public void BitPullToRefreshShouldNotThrowWhenTriggerIsZero() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 0); + }); + + component.Instance._OnMove(10m).GetAwaiter().GetResult(); + component.Instance._Refresh().GetAwaiter().GetResult(); + + Assert.IsNotNull(component.Find(".bit-ptr-spw")); } [TestMethod] @@ -115,6 +343,51 @@ public void BitPullToRefreshShouldRespectClassesAndStyles() StringAssert.Contains(spinner.GetAttribute("style"), "color:green"); } + [TestMethod] + public void BitPullToRefreshShouldRespectRefreshingClassesAndStyles() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var classes = new BitPullToRefreshClassStyles + { + SpinnerWrapperRefreshing = "custom-swr", + SpinnerRefreshing = "custom-spr" + }; + + var styles = new BitPullToRefreshClassStyles + { + SpinnerWrapperRefreshing = "border:1px solid red;", + SpinnerRefreshing = "outline:1px solid blue;" + }; + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Classes, classes); + parameters.Add(p => p.Styles, styles); + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("custom-swr")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "border:1px solid red"); + + var spinner = component.Find(".bit-ptr-spn"); + Assert.IsTrue(spinner.ClassList.Contains("custom-spr")); + StringAssert.Contains(spinner.GetAttribute("style"), "outline:1px solid blue"); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + + spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("custom-swr")); + + spinner = component.Find(".bit-ptr-spn"); + Assert.IsFalse(spinner.ClassList.Contains("custom-spr")); + } + [TestMethod] public void BitPullToRefreshShouldRenderChildContent() { @@ -130,6 +403,23 @@ public void BitPullToRefreshShouldRenderChildContent() Assert.AreEqual("content", content.TextContent); } + [TestMethod] + public void BitPullToRefreshShouldRenderCustomLoadingTemplate() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Loading, "
loading...
"); + }); + + var loading = component.Find(".custom-loading-content"); + Assert.IsNotNull(loading); + Assert.AreEqual("loading...", loading.TextContent); + + Assert.AreEqual(0, component.FindAll(".bit-ptr-spn svg").Count); + } + [TestMethod] public void BitPullToRefreshShouldCallJsSetupOnFirstRender() { @@ -139,4 +429,284 @@ public void BitPullToRefreshShouldCallJsSetupOnFirstRender() Context.JSInterop.VerifyInvoke("BitBlazorUI.PullToRefresh.setup"); } + + [TestMethod] + public void BitPullToRefreshShouldPassParametersToJsSetup() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 100); + parameters.Add(p => p.Factor, 2m); + parameters.Add(p => p.Margin, 20); + parameters.Add(p => p.Threshold, 10); + parameters.Add(p => p.IsEnabled, false); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(component.Instance.UniqueId, setup.Arguments[0]); + Assert.AreEqual(100, setup.Arguments[5]); + Assert.AreEqual(2m, setup.Arguments[6]); + Assert.AreEqual(20, setup.Arguments[7]); + Assert.AreEqual(10, setup.Arguments[8]); + Assert.AreEqual(false, setup.Arguments[9]); + } + + [TestMethod] + public void BitPullToRefreshShouldApplyDisabledClass() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + var root = component.Find(".bit-ptr"); + Assert.IsTrue(root.ClassList.Contains("bit-dis")); + } + + [TestMethod] + public void BitPullToRefreshShouldCallJsUpdateOnParameterChange() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.update"); + + var component = RenderComponent(); + + component.Render(parameters => + { + parameters.Add(p => p.Trigger, 120); + }); + + var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); + Assert.AreEqual(component.Instance.UniqueId, update.Arguments[0]); + Assert.AreEqual(120, update.Arguments[1]); + Assert.AreEqual(1.5m, update.Arguments[2]); + Assert.AreEqual(30, update.Arguments[3]); + Assert.AreEqual(0, update.Arguments[4]); + Assert.AreEqual(true, update.Arguments[5]); + + component.Render(parameters => + { + parameters.Add(p => p.Trigger, 120); + }); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Count); + } + + [TestMethod] + public void BitPullToRefreshShouldCallJsUpdateOnIsEnabledChange() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.update"); + + var component = RenderComponent(); + + component.Render(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); + Assert.AreEqual(false, update.Arguments[5]); + } + + [TestMethod] + public async Task BitPullToRefreshRefreshAsyncShouldCallJsRefresh() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.refresh"); + + var component = RenderComponent(); + + await component.Instance.RefreshAsync(); + + var refresh = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.refresh"].Single(); + Assert.AreEqual(component.Instance.UniqueId, refresh.Arguments[0]); + } + + [TestMethod] + public async Task BitPullToRefreshRefreshAsyncShouldNotCallJsRefreshWhenDisabled() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.refresh"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + await component.Instance.RefreshAsync(); + + Assert.IsEmpty(Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.refresh"]); + } + + [TestMethod] + public async Task BitPullToRefreshShouldShowCompleteStateAfterRefreshWhenCompleteDelayIsSet() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.CompleteDelay, 100); + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-cmp")); + Assert.IsTrue(spinnerWrapper.ClassList.Contains("bit-ptr-swr")); + + tcs.SetResult(); + + component.WaitForAssertion(() => + { + var sw = component.Find(".bit-ptr-spw"); + Assert.IsTrue(sw.ClassList.Contains("bit-ptr-cmp")); + Assert.IsFalse(sw.ClassList.Contains("bit-ptr-swr")); + Assert.IsFalse(sw.ClassList.Contains("bit-ptr-crl")); + StringAssert.Contains(sw.GetAttribute("style"), "margin-top:0px"); + StringAssert.Contains(sw.GetAttribute("style"), "width:35px"); + + var checkmark = component.Find(".bit-ptr-spn svg path"); + StringAssert.Contains(checkmark.GetAttribute("d"), "16.17"); + + var announcement = component.Find(".bit-ptr-vhd"); + Assert.AreEqual("Refresh complete", announcement.TextContent); + }); + + await refreshTask; + + spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-cmp")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + + [TestMethod] + public async Task BitPullToRefreshShouldRenderCompleteTemplateAndRespectCompleteClassesAndStyles() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.CompleteDelay, 100); + parameters.Add(p => p.Complete, "
done!
"); + parameters.Add(p => p.Classes, new BitPullToRefreshClassStyles { SpinnerWrapperComplete = "custom-swcmp", SpinnerComplete = "custom-spcmp" }); + parameters.Add(p => p.Styles, new BitPullToRefreshClassStyles { SpinnerWrapperComplete = "border:2px solid green;", SpinnerComplete = "outline:2px solid green;" }); + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + Assert.AreEqual(0, component.FindAll(".custom-complete-content").Count); + + tcs.SetResult(); + + component.WaitForAssertion(() => + { + var complete = component.Find(".custom-complete-content"); + Assert.AreEqual("done!", complete.TextContent); + + var sw = component.Find(".bit-ptr-spw"); + Assert.IsTrue(sw.ClassList.Contains("custom-swcmp")); + StringAssert.Contains(sw.GetAttribute("style"), "border:2px solid green"); + + var spinner = component.Find(".bit-ptr-spn"); + Assert.IsTrue(spinner.ClassList.Contains("custom-spcmp")); + StringAssert.Contains(spinner.GetAttribute("style"), "outline:2px solid green"); + }); + + await refreshTask; + + Assert.AreEqual(0, component.FindAll(".custom-complete-content").Count); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("custom-swcmp")); + } + + [TestMethod] + public void BitPullToRefreshShouldNotShowCompleteStateWhenCompleteDelayIsZero() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Complete, "
done!
"); + }); + + component.Instance._Refresh().GetAwaiter().GetResult(); + + Assert.AreEqual(0, component.FindAll(".custom-complete-content").Count); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + Assert.IsFalse(spinnerWrapper.ClassList.Contains("bit-ptr-cmp")); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:0px"); + } + + [TestMethod] + public void BitPullToRefreshShouldAnnounceRefreshingLabelWhileRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var announcement = component.Find(".bit-ptr-vhd"); + Assert.AreEqual(string.Empty, announcement.TextContent); + + var refreshTask = component.Instance._Refresh(); + + announcement = component.Find(".bit-ptr-vhd"); + Assert.AreEqual("Refreshing", announcement.TextContent); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + + announcement = component.Find(".bit-ptr-vhd"); + Assert.AreEqual(string.Empty, announcement.TextContent); + } + + [TestMethod] + public void BitPullToRefreshShouldAnnounceCustomRefreshingLabel() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.RefreshingLabel, "Loading new items"); + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + var announcement = component.Find(".bit-ptr-vhd"); + Assert.AreEqual("Loading new items", announcement.TextContent); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + } + + [TestMethod] + public async Task BitPullToRefreshShouldCallJsDisposeOnDispose() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.dispose"); + + var component = RenderComponent(); + var uniqueId = component.Instance.UniqueId; + + await Context.DisposeComponentsAsync(); + + var dispose = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.dispose"].Single(); + Assert.AreEqual(uniqueId, dispose.Arguments[0]); + } } From a294ce8726489f17fa416614d1f91c16ad5b1938 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 19:58:55 +0330 Subject: [PATCH 2/4] resolve review comments --- .../Utilities/PullToRefresh/BitPullToRefresh.razor | 1 + .../Utilities/PullToRefresh/BitPullToRefresh.razor.cs | 7 +++++++ .../Utilities/PullToRefresh/BitPullToRefresh.ts | 11 ++++++++++- .../Utilities/PullToRefresh/BitPullToRefreshTests.cs | 1 + 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor index fb4ea540212..9b712cd5ba1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor @@ -6,6 +6,7 @@ style="@StyleBuilder.Value" class="@ClassBuilder.Value" dir="@Dir?.ToString().ToLower()" + role="group" aria-label="@AriaLabel"> @(Anchor ?? ChildContent) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs index 78fa1dd3f6a..8733e51fd17 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs @@ -222,6 +222,13 @@ protected override async Task OnParametersSetAsync() if (_lastTrigger != Trigger || _lastFactor != Factor || _lastMargin != Margin || _lastThreshold != Threshold || _lastIsEnabled != IsEnabled) { + // js drops the pull height of an idle component when it gets disabled, so the managed + // side does the same, otherwise the indicator keeps rendering at the height it had. + if (IsEnabled is false && _refreshing is false && _completed is false) + { + _diff = 0; + } + CacheJsParameters(); await _js.BitPullToRefreshUpdate(UniqueId, Trigger, Factor, Margin, Threshold, IsEnabled); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts index 617cf8e4d08..be05e8126c8 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts @@ -56,7 +56,16 @@ return; } - if (diff <= options.threshold) return; + if (diff <= options.threshold) { + // Back inside the dead zone: drop the pull height so a release from here cannot + // trigger a refresh with the distance the pull had before it came back. + if (state.diff !== 0) { + state.diff = 0; + loadingEl.style.minHeight = '0'; + await dotnetObj.invokeMethodAsync('OnMove', 0); + } + return; + } if (e.cancelable) { e.preventDefault(); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs index 594dd0ce60c..0a5f1405fa4 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs @@ -42,6 +42,7 @@ public void BitPullToRefreshShouldRenderAriaLabel() }); var root = component.Find(".bit-ptr"); + Assert.AreEqual("group", root.GetAttribute("role")); Assert.AreEqual("pull down to refresh", root.GetAttribute("aria-label")); } From 839f3a24d81160cbf14ebd46074c2584460bb554 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Thu, 3 Sep 2026 13:45:14 +0330 Subject: [PATCH 3/4] local review --- .../PullToRefresh/BitPullToRefresh.razor | 11 +- .../PullToRefresh/BitPullToRefresh.razor.cs | 237 +++++- .../PullToRefresh/BitPullToRefresh.scss | 13 +- .../PullToRefresh/BitPullToRefresh.ts | 681 +++++++++++++----- .../BitPullToRefreshJsRuntimeExtensions.cs | 8 +- .../PullToRefresh/BitPullToRefreshDemo.razor | 91 ++- .../BitPullToRefreshDemo.razor.cs | 216 +++++- .../BitPullToRefreshDemo.razor.samples.cs | 145 +++- .../PullToRefresh/BitPullToRefreshTests.cs | 565 ++++++++++++++- 9 files changed, 1718 insertions(+), 249 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor index 9b712cd5ba1..0af4b29a2f9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor @@ -7,6 +7,7 @@ class="@ClassBuilder.Value" dir="@Dir?.ToString().ToLower()" role="group" + aria-busy="@(_refreshing ? "true" : null)" aria-label="@AriaLabel"> @(Anchor ?? ChildContent) @@ -18,7 +19,7 @@ @* The one thing on the component a screen reader reads. It is on the page from the first render rather than being inserted with its text already in it - a live region only announces what changes inside it - so the swap to the refreshing text is what gets announced. *@ - @(_refreshing ? RefreshingLabel : _completed ? CompleteLabel : string.Empty) + @GetScreenReaderText()
+ } } + else if (CanRelease && Release is not null) + { + @Release + } else if (Loading is not null) { @Loading } else { - + } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs index 8733e51fd17..30ee7772386 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.razor.cs @@ -7,14 +7,25 @@ namespace Bit.BlazorUI; /// public partial class BitPullToRefresh : BitComponentBase { + /// + /// The diameter, in pixels, the indicator's disc is drawn at once the pull has reached the trigger, and the + /// glyph inside it. Everything below the trigger is drawn as the same fraction of these, so the indicator + /// grows into place along with the pull rather than appearing at full size. + /// + private const decimal SpinnerWrapperSize = 35; + private const decimal SpinnerSize = 24; + private decimal _diff; private bool _completed; private bool _refreshing; private int _lastTrigger; private int _lastMargin; private int _lastThreshold; + private int _lastMaxPull; private decimal _lastFactor; private bool _lastIsEnabled; + private string? _lastScrollerSelector; + private ElementReference? _lastScrollerElement; private ElementReference _loadingRef = default!; @@ -34,6 +45,16 @@ public partial class BitPullToRefresh : BitComponentBase /// [Parameter] public BitPullToRefreshClassStyles? Classes { get; set; } + /// + /// The general color of the pull indicator. + /// + /// + /// It colors the glyph inside the indicator's disc, which is what the pull, the refresh and the complete + /// states all draw. Leave it unset to take the theme's primary foreground color, or to let + /// apply - a theme role always wins over a literal color. + /// + [Parameter, ResetStyleBuilder] public BitColor? Color { get; set; } + /// /// The custom template to replace the default checkmark svg shown while the complete state is visible. /// @@ -49,14 +70,42 @@ public partial class BitPullToRefresh : BitComponentBase /// [Parameter] public string CompleteLabel { get; set; } = "Refresh complete"; + /// + /// The custom css color of the pull indicator. + /// + /// + /// Any valid CSS color works here, currentColor included, which is what lets the indicator take the + /// color of the content it sits over. It only applies while is left unset. + /// + [Parameter, ResetStyleBuilder] public string? CustomColor { get; set; } + /// /// The factor to balance the pull height out. The pull-down distance gets divided by it, so higher values make the pull feel heavier. /// + /// + /// Values below 0.1 are treated as 0.1: a factor of zero would divide the travelled distance by nothing. + /// [Parameter] public decimal Factor { get; set; } = 1.5m; + /// + /// Gets or sets a value indicating whether the component takes the whole width of its container. + ///
+ /// The default value is false. + /// + /// + /// The component shrink-wraps its anchor by default, which is what keeps it out of the way of an anchor + /// that sizes itself. An anchor that is meant to fill a page or a layout region - the usual case on a + /// phone - needs the component around it to fill it too, which is what this does. + /// + [Parameter, ResetClassBuilder] public bool FullWidth { get; set; } + /// /// The custom loading template to replace the default loading svg. /// + /// + /// It is what the indicator shows while the pull is under way and while the refresh is running, so it + /// covers every state that and do not take over. + /// [Parameter] public RenderFragment? Loading { get; set; } /// @@ -64,6 +113,23 @@ public partial class BitPullToRefresh : BitComponentBase /// [Parameter] public int Margin { get; set; } = 30; + /// + /// The furthest the pull can travel, in pixels, past which it stops following the finger. + ///
+ /// The default value is 0, which stops the pull at . + ///
+ /// + /// A pull that stops dead the moment it has done its job feels like the gesture broke, so letting it + /// carry on a little past the trigger - a third of the trigger again is a good starting point - is what + /// makes the release feel deliberate rather than accidental. The indicator holds its full size over that + /// stretch; only the strip keeps growing. + ///
+ /// It is measured on the same damped scale as - the finger travels + /// times as far - and a value at or below the trigger leaves the pull stopping there, + /// which is what it does by default. + ///
+ [Parameter] public int MaxPull { get; set; } + /// /// The callback for when the trigger condition of the pull-down happens. /// @@ -77,6 +143,15 @@ public partial class BitPullToRefresh : BitComponentBase /// /// The callback for when the pull-down is in progress. /// + /// + /// It reports the pull height in pixels, which is capped at - or at + /// where the pull is allowed past it. Use where the + /// fraction of the way to the trigger is what matters rather than the distance itself. + ///
+ /// The gesture produces far more move events than the browser paints frames, so the reports are coalesced + /// to at most one per frame and never repeat a whole pixel; a handler still runs often enough that it + /// should stay cheap. + ///
[Parameter] public EventCallback OnPullMove { get; set; } /// @@ -94,6 +169,26 @@ public partial class BitPullToRefresh : BitComponentBase /// [Parameter] public string RefreshingLabel { get; set; } = "Refreshing"; + /// + /// The custom template to replace the default svg while the pull has passed the trigger and releasing starts the refresh. + /// + /// + /// The release state is the moment the gesture becomes a commitment, and showing something different for + /// it is what tells the user that letting go now will refresh. Without this the state is still there - the + /// indicator's disc changes color through the SpinnerWrapperCanRelease part - only the glyph inside it + /// stays the one draws. + /// + [Parameter] public RenderFragment? Release { get; set; } + + /// + /// The text that gets announced to screen readers while the pull has passed the trigger and releasing starts the refresh. + /// + /// + /// Set it to an empty string to leave the release state unannounced, which is worth doing where the pull + /// is a shortcut for a refresh the page also offers as a control. + /// + [Parameter] public string ReleaseLabel { get; set; } = "Release to refresh"; + /// /// The element that is the scroller in the anchor to control the behavior of the pull to refresh. /// @@ -102,6 +197,12 @@ public partial class BitPullToRefresh : BitComponentBase /// /// The CSS selector of the element that is the scroller in the anchor to control the behavior of the pull to refresh. /// + /// + /// It is looked up inside the anchor first and in the document afterwards, so a scroller that lives outside + /// the anchor can still be named. Point it at "body" to hang the gesture off the page's own scrolling. + ///
+ /// Left unset, the first element of the anchor is taken as the scroller. + ///
[Parameter] public string? ScrollerSelector { get; set; } /// @@ -117,6 +218,10 @@ public partial class BitPullToRefresh : BitComponentBase /// /// The pulling height in pixel that triggers the refresh. /// + /// + /// It is also the distance the indicator grows to its full size over, so it doubles as the scale of the + /// whole gesture. Values below 1 are treated as 1. + /// [Parameter] public int Trigger { get; set; } = 80; @@ -125,6 +230,22 @@ public partial class BitPullToRefresh : BitComponentBase + /// + /// Whether a refresh is currently running - the pull was released past the trigger, or + /// was called, and the callback has not returned yet. + /// + public bool IsRefreshing => _refreshing; + + /// + /// How far the current pull has come as a fraction of : 0 while nothing is being + /// pulled, and 1 once releasing would start a refresh. + /// + /// + /// It reads 1 for the whole of a refresh, since the indicator is held at its triggered height there. A + /// handler of reading this sees the value the move it was given produced. + /// + public decimal PullProgress => Math.Min(_diff / _Trigger, 1); + /// /// Starts the refresh process programmatically, showing the loading indicator and invoking the OnRefresh callback. /// It has no effect while the component is disabled, a refresh is already in progress or the complete state is visible. @@ -141,7 +262,7 @@ public async Task RefreshAsync() [JSInvokable("Refresh")] public async Task _Refresh() { - _diff = Trigger; + _diff = _Trigger; _refreshing = true; await InvokeAsync(StateHasChanged); try @@ -175,17 +296,32 @@ public async Task _OnStart(decimal top, decimal left, decimal width) [JSInvokable("OnMove")] public async Task _OnMove(decimal diff) { + // Only what the indicator is actually drawn from decides whether a re-render is worth it. A move that + // lands on the same whole pixel and the same release state renders identically, and re-rendering the + // component means re-rendering the whole anchor with it. + var changed = Math.Round(diff) != Math.Round(_diff) || CanReleaseAt(diff) != CanReleaseAt(_diff); + _diff = diff; - await InvokeAsync(StateHasChanged); + + if (changed) + { + await InvokeAsync(StateHasChanged); + } + await OnPullMove.InvokeAsync(diff); } [JSInvokable("OnEnd")] public async Task _OnEnd(decimal diff) { - if (diff < Trigger) + // A pull that fell short is dropped; one that made it is settled at the trigger, which is where the + // refresh about to be asked for holds it. Settling it here rather than leaving it standing is what + // keeps an overpull - see MaxPull - from being drawn for the round trip in between. + var settled = diff < _Trigger ? 0 : _Trigger; + + if (_diff != settled) { - _diff = 0; + _diff = settled; await InvokeAsync(StateHasChanged); } @@ -207,10 +343,37 @@ public async Task _OnCancel(decimal diff) protected override void RegisterCssClasses() { ClassBuilder.Register(() => Classes?.Root); + + ClassBuilder.Register(() => FullWidth ? "bit-ptr-flw" : string.Empty); } protected override void RegisterCssStyles() { + // Registered before Styles.Root so that a --bit-ptr-color written there still wins: in an inline style + // the last declaration of a custom property is the one that takes. + StyleBuilder.Register(() => Color switch + { + BitColor.Primary => "--bit-ptr-color:var(--bit-clr-pri)", + BitColor.Secondary => "--bit-ptr-color:var(--bit-clr-sec)", + BitColor.Tertiary => "--bit-ptr-color:var(--bit-clr-ter)", + BitColor.Info => "--bit-ptr-color:var(--bit-clr-inf)", + BitColor.Success => "--bit-ptr-color:var(--bit-clr-suc)", + BitColor.Warning => "--bit-ptr-color:var(--bit-clr-wrn)", + BitColor.SevereWarning => "--bit-ptr-color:var(--bit-clr-swr)", + BitColor.Error => "--bit-ptr-color:var(--bit-clr-err)", + BitColor.PrimaryBackground => "--bit-ptr-color:var(--bit-clr-bg-pri)", + BitColor.SecondaryBackground => "--bit-ptr-color:var(--bit-clr-bg-sec)", + BitColor.TertiaryBackground => "--bit-ptr-color:var(--bit-clr-bg-ter)", + BitColor.PrimaryForeground => "--bit-ptr-color:var(--bit-clr-fg-pri)", + BitColor.SecondaryForeground => "--bit-ptr-color:var(--bit-clr-fg-sec)", + BitColor.TertiaryForeground => "--bit-ptr-color:var(--bit-clr-fg-ter)", + BitColor.PrimaryBorder => "--bit-ptr-color:var(--bit-clr-brd-pri)", + BitColor.SecondaryBorder => "--bit-ptr-color:var(--bit-clr-brd-sec)", + BitColor.TertiaryBorder => "--bit-ptr-color:var(--bit-clr-brd-ter)", + // Color is nullable, so this also covers the unset case, where CustomColor applies. + _ => CustomColor.HasValue() ? $"--bit-ptr-color:{CustomColor}" : null + }); + StyleBuilder.Register(() => Styles?.Root); } @@ -220,7 +383,9 @@ protected override async Task OnParametersSetAsync() if (IsRendered is false) return; - if (_lastTrigger != Trigger || _lastFactor != Factor || _lastMargin != Margin || _lastThreshold != Threshold || _lastIsEnabled != IsEnabled) + if (_lastTrigger != Trigger || _lastFactor != Factor || _lastMargin != Margin || _lastThreshold != Threshold || + _lastMaxPull != MaxPull || _lastIsEnabled != IsEnabled || _lastScrollerSelector != ScrollerSelector || + !Nullable.Equals(_lastScrollerElement, ScrollerElement)) { // js drops the pull height of an idle component when it gets disabled, so the managed // side does the same, otherwise the indicator keeps rendering at the height it had. @@ -230,7 +395,7 @@ protected override async Task OnParametersSetAsync() } CacheJsParameters(); - await _js.BitPullToRefreshUpdate(UniqueId, Trigger, Factor, Margin, Threshold, IsEnabled); + await _js.BitPullToRefreshUpdate(UniqueId, ScrollerElement, ScrollerSelector, _Trigger, _Factor, _Margin, _Threshold, _MaxPull, IsEnabled); } } @@ -240,7 +405,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { CacheJsParameters(); var dotnetObj = DotNetObjectReference.Create(this); - await _js.BitPullToRefreshSetup(UniqueId, RootElement, _loadingRef, ScrollerElement, ScrollerSelector, Trigger, Factor, Margin, Threshold, IsEnabled, dotnetObj); + await _js.BitPullToRefreshSetup(UniqueId, RootElement, _loadingRef, ScrollerElement, ScrollerSelector, _Trigger, _Factor, _Margin, _Threshold, _MaxPull, IsEnabled, dotnetObj); } await base.OnAfterRenderAsync(firstRender); @@ -252,15 +417,48 @@ private void CacheJsParameters() _lastFactor = Factor; _lastMargin = Margin; _lastThreshold = Threshold; + _lastMaxPull = MaxPull; _lastIsEnabled = IsEnabled; + _lastScrollerSelector = ScrollerSelector; + _lastScrollerElement = ScrollerElement; } - private bool CanRelease => _refreshing is false && _completed is false && _diff > 0 && _diff >= Trigger; + // The four numbers that drive the gesture, held inside the range it can actually be drawn from. The same + // clamps are applied in js, so the height it draws and the size the indicator renders at never disagree. + private int _Trigger => Trigger < 1 ? 1 : Trigger; + private decimal _Factor => Factor < 0.1m ? 0.1m : Factor; + private int _Margin => Margin < 0 ? 0 : Margin; + private int _Threshold => Threshold < 0 ? 0 : Threshold; + private int _MaxPull => MaxPull < 0 ? 0 : MaxPull; + + // The pull height the indicator is drawn from, which stops at the trigger even where the pull itself is + // allowed past it: over that stretch the indicator holds its full size and the strip alone keeps growing. + private decimal _VisualDiff => _diff > _Trigger ? _Trigger : _diff; + + private bool CanRelease => CanReleaseAt(_diff); + + private bool CanReleaseAt(decimal diff) => _refreshing is false && _completed is false && diff > 0 && diff >= _Trigger; + + // The live region's whole content, which is what a screen reader announces every time it changes. Only one + // state speaks at a time, and the idle state says nothing so that the region falls silent between pulls. + private string GetScreenReaderText() + { + if (_refreshing) return RefreshingLabel; + if (_completed) return CompleteLabel; + if (CanRelease) return ReleaseLabel; + + return string.Empty; + } private string? GetSpinnerWrapperCssClasses() { List classes = ["bit-ptr-spw"]; + if (Classes?.SpinnerWrapper?.HasValue() ?? false) + { + classes.Add(Classes.SpinnerWrapper.Trim()); + } + if (CanRelease) { classes.Add("bit-ptr-crl"); @@ -291,21 +489,14 @@ private void CacheJsParameters() } } - if (Classes?.SpinnerWrapper?.HasValue() ?? false) - { - classes.Add(Classes.SpinnerWrapper.Trim()); - } - - return string.Join(' ', classes).Trim(); + return string.Join(' ', classes); } private string? GetSpinnerWrapperCssStyles() { - List styles = []; - var trigger = Trigger < 1 ? 1 : Trigger; - decimal size = 35 * _diff / trigger; + var size = SpinnerWrapperSize * _VisualDiff / _Trigger; - styles.Add(FormattableString.Invariant($"margin-top:{(_refreshing || _completed ? 0 : _diff / 2)}px;width:{size}px;height:{size}px")); + List styles = [FormattableString.Invariant($"margin-top:{(_refreshing || _completed ? 0 : _diff / 2)}px;width:{size}px;height:{size}px")]; if (Styles?.SpinnerWrapper?.HasValue() ?? false) { @@ -359,16 +550,16 @@ private void CacheJsParameters() classes.Add(Classes.SpinnerComplete.Trim()); } - return string.Join(' ', classes).Trim(); + return string.Join(' ', classes); } private string? GetSpinnerCssStyles() { - List styles = []; - var trigger = Trigger < 1 ? 1 : Trigger; - decimal size = 24 * _diff / trigger; + var trigger = _Trigger; + var diff = _VisualDiff; + var size = SpinnerSize * diff / trigger; - styles.Add(FormattableString.Invariant($"transform:rotate({(_diff - trigger) * 2}deg);width:{size}px;height:{size}px")); + List styles = [FormattableString.Invariant($"transform:rotate({(diff - trigger) * 2}deg);width:{size}px;height:{size}px")]; if (Styles?.Spinner?.HasValue() ?? false) { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss index 97c042aa00f..1910ca91cfe 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.scss @@ -3,12 +3,21 @@ .bit-ptr { width: fit-content; position: relative; + + // The color of the glyph inside the indicator, which the Color and CustomColor parameters write over. + --bit-ptr-color: #{$clr-fg-pri}; +} + +// An anchor that fills a page or a layout region needs the component around it to fill it too, which the +// shrink-wrapping default never does on its own. +.bit-ptr-flw { + width: 100%; } .bit-ptr-lod { top: 0; - left: 0; height: 0; + inset-inline-start: 0; z-index: 1; display: flex; overflow: hidden; @@ -37,9 +46,9 @@ .bit-ptr-spn { display: flex; - color: $clr-fg-pri; align-items: center; justify-content: center; + color: var(--bit-ptr-color); } .bit-ptr-spin { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts index be05e8126c8..0a01c58530e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefresh.ts @@ -1,6 +1,6 @@ namespace BitBlazorUI { export class PullToRefresh { - private static _refreshers: BitPullRefresher[] = []; + private static _refreshers: Record = {}; public static setup( id: string, @@ -12,183 +12,50 @@ factor: number, margin: number, threshold: number, + maxPull: number, enabled: boolean, dotnetObj: DotNetObject) { - const anchorEl = anchor ?? document.body as HTMLElement; - const scrollerEl = scrollerElement ?? ((scrollerSelector && (anchorEl.querySelector(scrollerSelector) ?? document.querySelector(scrollerSelector))) ?? (!!anchor ? anchor.children[0] : anchorEl)) as HTMLElement; - - const options: BitPullToRefreshOptions = { trigger, factor, margin, threshold, enabled }; - const state: BitPullToRefreshState = { diff: 0, startY: -1, refreshing: false }; - const isTouchDevice = Utils.isTouchDevice(); - - const getY = (e: TouchEvent | PointerEvent) => isTouchDevice ? (e as TouchEvent).touches[0].screenY : (e as PointerEvent).screenY; - - const onScroll = () => { - anchorEl.style.touchAction = (options.enabled && scrollerEl.scrollTop === 0) ? 'pan-x pan-down pinch-zoom' : ''; - scrollerEl.style.overscrollBehaviorY = options.enabled ? 'contain' : ''; - }; - const onStart = async (e: TouchEvent | PointerEvent): Promise => { - if (!isTouchDevice && (e as PointerEvent).button !== 0) return; - - if (!options.enabled || state.refreshing || scrollerEl.scrollTop !== 0) { - state.startY = -1; - return; - } - state.startY = getY(e); - loadingEl.classList.remove('bit-ptr-rtn'); - const bcr = anchorEl.getBoundingClientRect(); - loadingEl.style.width = `${bcr.width}px`; - - await dotnetObj.invokeMethodAsync('OnStart', bcr.top, bcr.left, bcr.width); - }; - const onMove = async (e: TouchEvent | PointerEvent): Promise => { - if (state.startY === -1 || state.refreshing) return; - - if (scrollerEl.scrollTop !== 0) { - state.startY = -1; - return; - } - - let diff = getY(e) - state.startY; - - if (diff < 0) { - state.startY = -1; - return; - } - - if (diff <= options.threshold) { - // Back inside the dead zone: drop the pull height so a release from here cannot - // trigger a refresh with the distance the pull had before it came back. - if (state.diff !== 0) { - state.diff = 0; - loadingEl.style.minHeight = '0'; - await dotnetObj.invokeMethodAsync('OnMove', 0); - } - return; - } - - if (e.cancelable) { - e.preventDefault(); - e.stopPropagation(); - } - - diff = (diff - options.threshold) / options.factor; - diff = diff > options.trigger ? options.trigger : diff; - state.diff = diff; - loadingEl.style.minHeight = `${diff * options.factor + options.margin}px`; - - await dotnetObj.invokeMethodAsync('OnMove', diff); - }; - const onEnd = async (e: TouchEvent | PointerEvent): Promise => { - if (state.startY === -1 || state.refreshing) return; - state.startY = -1; - - try { - await dotnetObj.invokeMethodAsync('OnEnd', state.diff); - - if (state.diff >= options.trigger) { - state.refreshing = true; - await dotnetObj.invokeMethodAsync('Refresh'); - } - } finally { - state.diff = 0; - state.refreshing = false; - PullToRefresh.snapBack(loadingEl); - } - }; - const onCancel = async (e: TouchEvent | PointerEvent): Promise => { - if (state.startY === -1 || state.refreshing) return; - state.startY = -1; - - const diff = state.diff; - state.diff = 0; - PullToRefresh.snapBack(loadingEl); - - await dotnetObj.invokeMethodAsync('OnCancel', diff); - }; - - if (isTouchDevice) { - anchorEl.addEventListener('touchstart', onStart); - anchorEl.addEventListener('touchmove', onMove, { passive: false }); - anchorEl.addEventListener('touchend', onEnd); - anchorEl.addEventListener('touchcancel', onCancel); - } else { - anchorEl.addEventListener('pointerdown', onStart); - anchorEl.addEventListener('pointermove', onMove); - anchorEl.addEventListener('pointerup', onEnd); - anchorEl.addEventListener('pointerleave', onCancel, false); - anchorEl.addEventListener('pointercancel', onCancel); - } - scrollerEl.addEventListener('scroll', onScroll); - onScroll(); - - const refresher = new BitPullRefresher(id, anchorEl, loadingEl, options, state, dotnetObj, onScroll); - refresher.setDisposer(() => { - if (isTouchDevice) { - anchorEl.removeEventListener('touchstart', onStart); - anchorEl.removeEventListener('touchmove', onMove); - anchorEl.removeEventListener('touchend', onEnd); - anchorEl.removeEventListener('touchcancel', onCancel); - } else { - anchorEl.removeEventListener('pointerdown', onStart); - anchorEl.removeEventListener('pointermove', onMove); - anchorEl.removeEventListener('pointerup', onEnd); - anchorEl.removeEventListener('pointerleave', onCancel, false); - anchorEl.removeEventListener('pointercancel', onCancel); - } - scrollerEl.removeEventListener('scroll', onScroll); - anchorEl.style.touchAction = ''; - scrollerEl.style.overscrollBehaviorY = ''; - loadingEl.style.minHeight = ''; - }); - PullToRefresh._refreshers.push(refresher); + // An id that is already registered would otherwise leave the previous refresher's listeners on the + // anchor forever, so a component re-created against the same id keeps a single live gesture. + PullToRefresh.dispose(id); + + PullToRefresh._refreshers[id] = new BitPullRefresher( + id, + anchor ?? document.body, + loadingEl, + scrollerElement, + scrollerSelector, + { trigger, factor, margin, threshold, maxPull, enabled }, + dotnetObj); } public static update( id: string, + scrollerElement: HTMLElement | undefined, + scrollerSelector: string | undefined, trigger: number, factor: number, margin: number, threshold: number, + maxPull: number, enabled: boolean) { - const refresher = PullToRefresh._refreshers.find(r => r.id === id); - if (!refresher) return; - - refresher.options.trigger = trigger; - refresher.options.factor = factor; - refresher.options.margin = margin; - refresher.options.threshold = threshold; - refresher.options.enabled = enabled; - - if (!enabled && !refresher.state.refreshing) { - refresher.state.diff = 0; - refresher.state.startY = -1; - PullToRefresh.snapBack(refresher.loadingEl); - } - - refresher.syncTouchAction(); + PullToRefresh._refreshers[id]?.update( + scrollerElement, + scrollerSelector, + { trigger, factor, margin, threshold, maxPull, enabled }); } public static async refresh(id: string) { - const refresher = PullToRefresh._refreshers.find(r => r.id === id); - if (!refresher) return; - - await refresher.refresh(); + await PullToRefresh._refreshers[id]?.refresh(); } public static dispose(id: string) { - const refresher = PullToRefresh._refreshers.find(r => r.id === id); + const refresher = PullToRefresh._refreshers[id]; if (!refresher) return; - PullToRefresh._refreshers = PullToRefresh._refreshers.filter(r => r.id !== id); + delete PullToRefresh._refreshers[id]; refresher.dispose(); } - - private static snapBack(loadingEl: HTMLElement) { - loadingEl.classList.add('bit-ptr-rtn'); - void loadingEl.offsetHeight; - loadingEl.style.minHeight = '0'; - } } interface BitPullToRefreshOptions { @@ -196,68 +63,504 @@ factor: number; margin: number; threshold: number; + maxPull: number; enabled: boolean; } - interface BitPullToRefreshState { - diff: number; - startY: number; - refreshing: boolean; - } + // How far the finger travels before the gesture decides whether it is a pull or a sideways swipe. Below + // it nothing is reported and nothing is prevented, so the few pixels a horizontal scroller or a carousel + // needs to claim the gesture are left to the browser. + const AXIS_SLOP = 8; + + // A pull is one pointer travelling down. 0 while that is still undecided, 1 once it is a pull, -1 once the + // gesture has been given up on - a sideways swipe, a second finger, a scroller that is no longer at its top. + const enum BitPullAxis { Undecided = 0, Vertical = 1, Abandoned = -1 } class BitPullRefresher { - id: string; - anchorEl: HTMLElement; - loadingEl: HTMLElement; - options: BitPullToRefreshOptions; - state: BitPullToRefreshState; - dotnetObj: DotNetObject; - syncTouchAction: () => void; - disposer: () => void = () => { }; + readonly id: string; + + private readonly anchorEl: HTMLElement; + private readonly loadingEl: HTMLElement; + private readonly dotnetObj: DotNetObject; + + private scrollerEl: HTMLElement; + private scrollerElement?: HTMLElement; + private scrollerSelector?: string; + private options: BitPullToRefreshOptions; + + private startX = 0; + private startY = -1; + private axis: BitPullAxis = BitPullAxis.Undecided; + private diff = 0; + private refreshing = false; + private pointerId = -1; + + // The pull is reported to .NET at most once per frame, and never twice with the same rendered pixel: + // a move event arrives far more often than a frame, and every report is an interop round trip that + // re-renders the component - on a Blazor Server circuit, one over the network. + private frameId = 0; + private pendingDiff = -1; + private reportedDiff = -1; + private reporting = false; + + // The two inline styles the gesture writes on elements it does not own, remembered so that disposing + // puts back whatever the application had there rather than blanking it. + private readonly anchorTouchAction: string; + private scrollerOverscroll = ''; + + private resizeObserver?: ResizeObserver; + private touchActionInEffect = ''; + private overscrollInEffect = ''; constructor(id: string, anchorEl: HTMLElement, loadingEl: HTMLElement, + scrollerElement: HTMLElement | undefined, + scrollerSelector: string | undefined, options: BitPullToRefreshOptions, - state: BitPullToRefreshState, - dotnetObj: DotNetObject, - syncTouchAction: () => void) { + dotnetObj: DotNetObject) { this.id = id; this.anchorEl = anchorEl; this.loadingEl = loadingEl; - this.options = options; - this.state = state; this.dotnetObj = dotnetObj; - this.syncTouchAction = syncTouchAction; + this.options = BitPullRefresher.normalize(options); + this.anchorTouchAction = anchorEl.style.touchAction; + + this.scrollerElement = scrollerElement; + this.scrollerSelector = scrollerSelector; + this.scrollerEl = this.resolveScroller(); + this.scrollerOverscroll = this.scrollerEl.style.overscrollBehaviorY; + + // Touch and pointer listeners are both registered, and the pointer ones step aside for a touch + // pointer. Choosing between them up front on a "is this a touch device" answer left every + // touch-capable laptop without mouse support, since such a device reports itself as touch and then + // never sees a touch event from its mouse. + this.anchorEl.addEventListener('touchstart', this.onTouchStart, { passive: true }); + // The only listener that has to stay non-passive: it is the one that prevents the browser's own + // overscroll while the pull is being drawn. + this.anchorEl.addEventListener('touchmove', this.onTouchMove, { passive: false }); + this.anchorEl.addEventListener('touchend', this.onTouchEnd, { passive: true }); + this.anchorEl.addEventListener('touchcancel', this.onTouchCancel, { passive: true }); + this.anchorEl.addEventListener('pointerdown', this.onPointerDown); + this.anchorEl.addEventListener('pointermove', this.onPointerMove); + this.anchorEl.addEventListener('pointerup', this.onPointerUp); + this.anchorEl.addEventListener('pointercancel', this.onPointerCancel); + this.anchorEl.addEventListener('lostpointercapture', this.onPointerCancel); + + this.bindScroller(); + + // The loading strip is positioned out of the flow, so it has no width of its own to inherit. It is + // kept in step with the anchor rather than measured once per pull, so that a resize, a rotation or + // a zoom during a refresh - or a programmatic refresh before any pull has happened - still draws it + // across the whole anchor. + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.syncWidth()); + this.resizeObserver.observe(this.anchorEl); + } else { + this.syncWidth(); + } + } + + public update(scrollerElement: HTMLElement | undefined, scrollerSelector: string | undefined, options: BitPullToRefreshOptions) { + this.options = BitPullRefresher.normalize(options); + + if (scrollerElement !== this.scrollerElement || scrollerSelector !== this.scrollerSelector) { + this.scrollerElement = scrollerElement; + this.scrollerSelector = scrollerSelector; + + const scrollerEl = this.resolveScroller(); + if (scrollerEl !== this.scrollerEl) { + this.unbindScroller(); + this.scrollerEl = scrollerEl; + this.scrollerOverscroll = scrollerEl.style.overscrollBehaviorY; + this.overscrollInEffect = ''; + this.bindScroller(); + } + } + + if (!this.options.enabled && !this.refreshing) { + this.reset(); + this.snapBack(); + } + + this.syncScrollStyles(); } public async refresh() { - if (!this.options.enabled || this.state.refreshing) return; - this.state.refreshing = true; - this.state.diff = 0; - this.state.startY = -1; + if (!this.options.enabled || this.refreshing) return; + + this.refreshing = true; + this.reset(); try { - const bcr = this.anchorEl.getBoundingClientRect(); - this.loadingEl.style.width = `${bcr.width}px`; + this.syncWidth(); this.loadingEl.classList.add('bit-ptr-rtn'); void this.loadingEl.offsetHeight; - this.loadingEl.style.minHeight = `${this.options.trigger * this.options.factor + this.options.margin}px`; + this.loadingEl.style.minHeight = `${this.pullHeight(this.options.trigger)}px`; await this.dotnetObj.invokeMethodAsync('Refresh'); } finally { - this.state.refreshing = false; + this.refreshing = false; this.loadingEl.style.minHeight = '0'; } } - public setDisposer(disposer: () => void) { - this.disposer = disposer; - } - public dispose() { - this.disposer(); + this.cancelFrame(); + + this.anchorEl.removeEventListener('touchstart', this.onTouchStart); + this.anchorEl.removeEventListener('touchmove', this.onTouchMove); + this.anchorEl.removeEventListener('touchend', this.onTouchEnd); + this.anchorEl.removeEventListener('touchcancel', this.onTouchCancel); + this.anchorEl.removeEventListener('pointerdown', this.onPointerDown); + this.anchorEl.removeEventListener('pointermove', this.onPointerMove); + this.anchorEl.removeEventListener('pointerup', this.onPointerUp); + this.anchorEl.removeEventListener('pointercancel', this.onPointerCancel); + this.anchorEl.removeEventListener('lostpointercapture', this.onPointerCancel); + + this.unbindScroller(); + this.resizeObserver?.disconnect(); + this.releasePointer(); + + this.anchorEl.style.touchAction = this.anchorTouchAction; + this.loadingEl.style.minHeight = ''; + this.dotnetObj?.dispose(); } + + + + // ---- gesture ---- + + private onTouchStart = (e: TouchEvent) => { + // Anything but a single finger is a pinch or a two-finger scroll, never a pull. + if (e.touches.length !== 1) return this.abandon(); + + this.start(e.touches[0].screenX, e.touches[0].screenY); + }; + + private onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) return this.abandon(); + + this.move(e, e.touches[0].screenX, e.touches[0].screenY); + }; + + private onTouchEnd = () => { void this.end(); }; + + private onTouchCancel = () => { void this.cancel(); }; + + private onPointerDown = (e: PointerEvent) => { + // A touch pointer is already covered by the touch listeners above. + if (e.pointerType === 'touch' || e.button !== 0) return; + + this.pointerId = e.pointerId; + this.start(e.screenX, e.screenY); + }; + + private onPointerMove = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.pointerId !== this.pointerId) return; + + this.move(e, e.screenX, e.screenY); + }; + + private onPointerUp = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.pointerId !== this.pointerId) return; + + this.releasePointer(); + void this.end(); + }; + + private onPointerCancel = (e: PointerEvent) => { + if (e.pointerType === 'touch' || e.pointerId !== this.pointerId) return; + + this.releasePointer(); + void this.cancel(); + }; + + private start(x: number, y: number) { + if (!this.options.enabled || this.refreshing || this.getScrollTop() > 0) return this.abandon(); + + this.startX = x; + this.startY = y; + this.axis = BitPullAxis.Undecided; + this.diff = 0; + this.reportedDiff = -1; + this.loadingEl.classList.remove('bit-ptr-rtn'); + + const bcr = this.anchorEl.getBoundingClientRect(); + this.loadingEl.style.width = `${bcr.width}px`; + + this.invoke('OnStart', bcr.top, bcr.left, bcr.width); + } + + private move(e: TouchEvent | PointerEvent, x: number, y: number) { + if (this.startY === -1 || this.axis === BitPullAxis.Abandoned || this.refreshing) return; + + // A scroller that has left its top while the finger is down means the gesture belongs to the + // scroller, not to the pull. + if (this.getScrollTop() > 0) return this.abandon(); + + const dx = x - this.startX; + const dy = y - this.startY; + + if (this.axis === BitPullAxis.Undecided) { + // Nothing is claimed until the finger has moved far enough to say which way it is going, so a + // horizontal scroller or a carousel inside the anchor keeps the gestures that belong to it. + if (Math.abs(dx) < AXIS_SLOP && Math.abs(dy) < AXIS_SLOP) return; + if (Math.abs(dx) > Math.abs(dy) || dy <= 0) return this.abandon(); + + this.axis = BitPullAxis.Vertical; + + // Taken only now, and not on pointerdown, so that a plain click or a sideways drag inside the + // anchor is never retargeted. It is what keeps a mouse pull alive once the cursor leaves the + // anchor, which used to cancel it. + if (this.pointerId !== -1 && 'pointerId' in e) { + try { this.anchorEl.setPointerCapture(this.pointerId); } catch { /* the pointer is already gone */ } + } + } + + if (dy <= 0) return this.abandon(); + + if (dy <= this.options.threshold) { + // Back inside the dead zone: drop the pull height so a release from here cannot trigger a + // refresh with the distance the pull had before it came back. + if (this.diff !== 0) { + this.diff = 0; + this.loadingEl.style.minHeight = '0'; + this.queueMove(0); + } + return; + } + + if (e.cancelable) { + e.preventDefault(); + e.stopPropagation(); + } + + // Past the trigger the pull keeps following the finger up to the overpull limit, so that the + // gesture does not go dead the moment it has done its job; without one the limit is the trigger + // itself, which is what the component has always done. + const limit = Math.max(this.options.maxPull, this.options.trigger); + this.diff = Math.min((dy - this.options.threshold) / this.options.factor, limit); + this.loadingEl.style.minHeight = `${this.pullHeight(this.diff)}px`; + + this.queueMove(this.diff); + } + + private async end() { + if (this.startY === -1 || this.refreshing) return; + + const diff = this.axis === BitPullAxis.Vertical ? this.diff : 0; + const willRefresh = diff >= this.options.trigger; + this.reset(); + + // Claimed before the first round trip rather than between the two: a touch landing while the end + // of the gesture is still being reported would otherwise start a second pull on top of the refresh + // this one is about to run. + this.refreshing = willRefresh; + + try { + await this.invoke('OnEnd', diff); + + if (willRefresh) { + await this.invoke('Refresh'); + } + } finally { + this.refreshing = false; + this.snapBack(); + } + } + + private async cancel() { + if (this.startY === -1 || this.refreshing) return; + + const diff = this.axis === BitPullAxis.Vertical ? this.diff : 0; + this.reset(); + this.snapBack(); + + await this.invoke('OnCancel', diff); + } + + // Gives up on the gesture: it turned out to be a scroll, a sideways swipe or a pinch. One that never + // drew anything was never claimed, so there is nothing for the managed side to hear about; one that + // did is a pull taken away before it was released, which is exactly a cancel - reporting it is what + // keeps the managed pull height from staying behind at the distance the abandoned pull had reached. + private abandon() { + if (this.diff !== 0) { + void this.cancel(); + return; + } + + this.reset(); + } + + private reset() { + this.cancelFrame(); + this.startY = -1; + this.axis = BitPullAxis.Abandoned; + this.diff = 0; + this.pendingDiff = -1; + this.reportedDiff = -1; + this.releasePointer(); + } + + private releasePointer() { + if (this.pointerId === -1) return; + + const pointerId = this.pointerId; + this.pointerId = -1; + try { + if (this.anchorEl.hasPointerCapture(pointerId)) { + this.anchorEl.releasePointerCapture(pointerId); + } + } catch { /* the pointer is already gone */ } + } + + private snapBack() { + this.loadingEl.classList.add('bit-ptr-rtn'); + void this.loadingEl.offsetHeight; + this.loadingEl.style.minHeight = '0'; + } + + // The height the strip is drawn at for a pull of the given (already damped) distance: the raw finger + // travel that produced it, plus the configured margin. + private pullHeight(diff: number) { + return diff * this.options.factor + this.options.margin; + } + + + + // ---- reporting ---- + + private queueMove(diff: number) { + this.pendingDiff = diff; + + if (this.frameId !== 0) return; + + this.frameId = requestAnimationFrame(() => { + this.frameId = 0; + void this.flushMove(); + }); + } + + private async flushMove() { + // A report is still out; the value that arrived meanwhile is picked up when it comes back, so the + // round trips are never allowed to interleave and land out of order. + if (this.reporting || this.pendingDiff < 0) return; + + const diff = this.pendingDiff; + const rounded = Math.round(diff); + if (rounded === this.reportedDiff) return; + + this.reporting = true; + this.reportedDiff = rounded; + try { + await this.invoke('OnMove', diff); + } finally { + this.reporting = false; + } + + // The last frame of a fast pull is never dropped: whatever came in while the call was out is + // reported now. + if (this.pendingDiff >= 0 && Math.round(this.pendingDiff) !== this.reportedDiff) { + this.queueMove(this.pendingDiff); + } + } + + private cancelFrame() { + if (this.frameId === 0) return; + + cancelAnimationFrame(this.frameId); + this.frameId = 0; + } + + private async invoke(method: string, ...args: any[]) { + try { + await this.dotnetObj.invokeMethodAsync(method, ...args); + } catch (e) { + // The circuit or the component is gone; a pull that can no longer be reported is not an error + // the page should see. + console.error('BitBlazorUI.PullToRefresh:', e); + } + } + + + + // ---- scroller ---- + + private resolveScroller(): HTMLElement { + if (this.scrollerElement) return this.scrollerElement; + + if (this.scrollerSelector) { + const el = this.anchorEl.querySelector(this.scrollerSelector) ?? document.querySelector(this.scrollerSelector); + if (el) return el as HTMLElement; + } + + // The loading strip is a child of the anchor too, and it never scrolls - taking it for the scroller + // would leave the gesture reading a scrollTop that is always zero. + const first = this.anchorEl.firstElementChild; + + return (first && first !== this.loadingEl) ? first as HTMLElement : this.anchorEl; + } + + // The document's scroll offset does not live on the element that is styled as the scroller: in + // standards mode body.scrollTop stays 0 however far the page is scrolled, which used to leave a + // whole-page pull to refresh permanently at "the top". A rubber-banding iOS scroller also reports a + // negative offset, which is still the top as far as the pull is concerned. + private getScrollTop() { + const el = this.scrollerEl; + + return (el === document.body || el === document.documentElement) + ? (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) + : el.scrollTop; + } + + private bindScroller() { + this.scrollerEl.addEventListener('scroll', this.onScroll, { passive: true }); + this.syncScrollStyles(); + } + + private unbindScroller() { + this.scrollerEl.removeEventListener('scroll', this.onScroll); + this.scrollerEl.style.overscrollBehaviorY = this.scrollerOverscroll; + } + + private onScroll = () => this.syncScrollStyles(); + + // Written only when the value actually changes: a scroll handler that assigns an inline style on every + // event makes the browser recalculate styles for the whole subtree at scroll speed. + private syncScrollStyles() { + const touchAction = (this.options.enabled && this.getScrollTop() <= 0) ? 'pan-x pan-down pinch-zoom' : this.anchorTouchAction; + if (touchAction !== this.touchActionInEffect) { + this.touchActionInEffect = touchAction; + this.anchorEl.style.touchAction = touchAction; + } + + const overscroll = this.options.enabled ? 'contain' : this.scrollerOverscroll; + if (overscroll !== this.overscrollInEffect) { + this.overscrollInEffect = overscroll; + this.scrollerEl.style.overscrollBehaviorY = overscroll; + } + } + + private syncWidth() { + this.loadingEl.style.width = `${this.anchorEl.getBoundingClientRect().width}px`; + } + + + + // A factor of zero divides the pull distance by nothing and a negative one pulls the indicator + // upwards, so the numbers the managed side sends are held inside the range the gesture can draw. The + // same clamps are applied there, so the height js draws and the size the component renders agree. + private static normalize(options: BitPullToRefreshOptions): BitPullToRefreshOptions { + return { + trigger: Math.max(options.trigger || 0, 1), + factor: Math.max(options.factor || 0, 0.1), + margin: Math.max(options.margin || 0, 0), + threshold: Math.max(options.threshold || 0, 0), + maxPull: Math.max(options.maxPull || 0, 0), + enabled: options.enabled, + }; + } } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs index a714a3d8f0e..15691cdc6e9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/PullToRefresh/BitPullToRefreshJsRuntimeExtensions.cs @@ -12,21 +12,25 @@ internal static ValueTask BitPullToRefreshSetup(this IJSRuntime jsRuntime, decimal factor, int margin, int threshold, + int maxPull, bool enabled, DotNetObjectReference? dotnetObjectReference) { - return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.setup", id, anchor, loading, scrollerElement, scrollerSelector, trigger, factor, margin, threshold, enabled, dotnetObjectReference); + return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.setup", id, anchor, loading, scrollerElement, scrollerSelector, trigger, factor, margin, threshold, maxPull, enabled, dotnetObjectReference); } internal static ValueTask BitPullToRefreshUpdate(this IJSRuntime jsRuntime, string id, + ElementReference? scrollerElement, + string? scrollerSelector, int trigger, decimal factor, int margin, int threshold, + int maxPull, bool enabled) { - return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.update", id, trigger, factor, margin, threshold, enabled); + return jsRuntime.InvokeVoid("BitBlazorUI.PullToRefresh.update", id, scrollerElement, scrollerSelector, trigger, factor, margin, threshold, maxPull, enabled); } internal static ValueTask BitPullToRefreshRefresh(this IJSRuntime jsRuntime, string id) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor index 790b5579bf1..300b36da509 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor @@ -2,17 +2,18 @@ + Description="The PullToRefresh component adds the pull down to refresh gesture to a page or any scrollable element, for touch and mouse alike, with a tunable trigger distance and resistance, templates for every stage of the pull, and a refresh that can also be started from code." /> -
Wrap any scrollable element in a BitPullToRefresh and handle the OnRefresh event. Drag the content down from its top (by touch or mouse) and release it past the trigger distance to start the refresh; the indicator stays visible until the OnRefresh callback completes.
+
Wrap any scrollable element in a BitPullToRefresh and handle OnRefresh. Drag the content down from its top - by touch or by mouse - and release past the trigger distance to start the refresh; the indicator stays open until the OnRefresh callback returns, so an awaited handler is all it takes to show the wait. The gesture only engages while the scroller is at its very top, and a sideways drag is left to whatever is inside the anchor.

@@ -25,7 +26,7 @@ -
Replace the default spinner with any custom content using the Loading template; it scales and rotates with the pull progress just like the default one.
+
Replace the default spinner with any content through the Loading template. It is drawn inside the indicator, so it scales and rotates with the pull exactly as the default glyph does, and it spins on its own once the refresh starts. Size it in relative units - the box it sits in grows with the pull.

@@ -48,7 +49,7 @@
-
Multiple BitPullToRefresh instances work independently on the same page, each with its own anchor, state and OnRefresh handler.
+
Any number of instances live side by side on one page, each with its own anchor, its own gesture state and its own OnRefresh handler; pulling one never disturbs another.

@@ -76,7 +77,7 @@ -
An illustrative example of integrating this component into a straightforward mobile application. The ScrollerSelector parameter points at the actual scrollable element inside the anchor, so the pull gesture only engages when that scroller sits at its very top.
+
The component inside a phone-shaped layout, which is where the gesture belongs. ScrollerSelector points at the element that actually scrolls inside the anchor, so the pull only engages while that list is at its top rather than whenever the anchor is; FullWidth makes the component fill the layout region instead of shrink-wrapping its content, which is what it does by default.

@@ -92,7 +93,7 @@
- +
@foreach (var (idx, i) in advancedItems) { @@ -107,7 +108,7 @@ -
Setting IsEnabled to false turns the pull gesture off entirely while leaving the anchor content fully interactive; flipping it back on re-enables the gesture right away.
+
IsEnabled set to false turns the gesture off and hands scrolling and overscroll back to the browser, while the anchor content stays fully interactive; an indicator left open by a disabled component snaps away. Flipping it back on re-arms the gesture at once.


@@ -122,14 +123,15 @@
-
Fine-tune the pull behavior: Trigger is the pull height that starts the refresh, Factor damps the finger movement (higher values make the pull feel heavier), Margin adds extra space above the indicator, and Threshold is a dead zone the pull must travel before the indicator appears. Changes to these parameters apply immediately, even after the component has rendered.
+
The five numbers that decide how the pull feels. Trigger is the height that starts the refresh, and also the distance the indicator grows to its full size over. Factor divides the finger travel, so a higher value makes the pull heavier and longer. Margin adds space above the indicator. Threshold is a dead zone the finger crosses before anything appears, which is what keeps an accidental nudge from opening the indicator - come back inside it and the pull resets. MaxPull lets the pull carry on past the trigger instead of going dead there, with the indicator holding its full size over that stretch; at 0 it stops at the trigger. All five take effect the moment they change, long after the component has rendered.

+ Threshold="(int)threshold" + MaxPull="(int)maxPull">
@foreach (var (idx, i) in behaviorItems) { @@ -145,12 +147,14 @@
+
+
-
Start a refresh from code by calling the RefreshAsync method on a component reference; it opens the loading indicator, runs the OnRefresh callback and closes the indicator when the callback completes, exactly like a pull gesture would.
+
RefreshAsync starts a refresh from code on a component reference: it opens the indicator, runs OnRefresh and closes the indicator when the callback returns, exactly as a released pull does, and the task it returns completes with it. It is also the keyboard-reachable way to refresh, which a gesture on its own can never be. It does nothing while the component is disabled or a refresh is already running, and IsRefreshing says which.

Refresh

@@ -165,7 +169,7 @@
-
The component reports every stage of the gesture: OnPullStart provides the anchor's position and width when the pull begins, OnPullMove streams the current pull height, OnPullEnd fires on release with the final height, OnPullCancel fires when the gesture gets canceled before release, and OnRefresh runs when the pull gets released at the trigger height.
+
Every stage of the gesture is reported: OnPullStart hands over the anchor's position and width as the pull begins, OnPullMove streams the pull height - coalesced to one report per frame, and capped at Trigger - OnPullEnd fires on release with the final height, OnPullCancel when the gesture is taken away before release, and OnRefresh once a release past the trigger commits to it. PullProgress reads the same pull as a fraction of the trigger.

-
Set CompleteDelay to a positive number of milliseconds to keep a brief success indicator visible after the refresh finishes, before the loading area snaps back; by default it shows a checkmark, and the Complete template replaces it with any custom content, like the emoji in the second instance below.
+
A refresh that closes the instant it finishes leaves nothing to say it worked. CompleteDelay holds a success indicator open for that many milliseconds before the strip snaps back - a checkmark by default, replaced by whatever the Complete template draws, like the emoji on the right - and announces CompleteLabel to screen readers as it appears. Left at 0, the complete state never happens.

@@ -217,8 +221,52 @@
- -
Empower customization by overriding default styles and classes, allowing tailored design modifications to suit specific UI requirements. The Styles and Classes parameters target each part of the component, including the state-specific SpinnerCanRelease and SpinnerRefreshing entries; pull far enough to see the spinner change once releasing would start the refresh.
+ +
Once the pull passes the trigger, releasing starts the refresh, and the Release template replaces the indicator's glyph for exactly that moment so the commitment is visible before the finger lifts; going back below the trigger restores the Loading glyph. The same crossing is announced to screen readers through ReleaseLabel, which an empty string silences.
+
+ + +
+ @foreach (var (idx, i) in releaseItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + + + + +
+
+ + +
The Color parameter paints the indicator's glyph with one of the theme's general colors, so the gesture matches the surface it belongs to; CustomColor takes any CSS color for the cases a theme role does not cover, and stands down as soon as Color is set.
+
+
+ +
+ @foreach (var (idx, i) in colorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + +
+ @foreach (var (idx, i) in customColorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
+
+ + +
Styles and Classes reach every part of the component - the strip, the indicator's disc and the glyph inside it - and each of those three carries its own release, refreshing and complete variant on top, so one state can be styled without restyling the rest. Pull far enough to see the release variant take over.


+ +
A pull is a vertical gesture, so Dir leaves it exactly as it is; what the direction changes is the content of the anchor and of any custom template, and the loading strip, which is pinned to the start edge and so follows the writing direction across the anchor.
+
+ +
+ @foreach (var (idx, i) in rtlItems) + { +
@(idx.ToString().PadLeft(2, '0')) .مورد @i
+ } +
+
+
+ diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs index db7534684a5..85312099403 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.cs @@ -28,6 +28,15 @@ public partial class BitPullToRefreshDemo Href = "#class-styles", }, new() + { + Name = "Color", + Type = "BitColor?", + DefaultValue = "null", + Description = "The general color of the pull indicator. It colors the glyph inside the indicator's disc, which the pull, the refresh and the complete states all draw.", + LinkType = LinkType.Link, + Href = "#color-enum", + }, + new() { Name = "Complete", Type = "RenderFragment?", @@ -49,18 +58,32 @@ public partial class BitPullToRefreshDemo Description = "The text that gets announced to screen readers while the complete state is visible after a successful refresh.", }, new() + { + Name = "CustomColor", + Type = "string?", + DefaultValue = "null", + Description = "The custom css color of the pull indicator. It only applies while Color is left unset.", + }, + new() { Name = "Factor", Type = "decimal", DefaultValue = "1.5", - Description = "The factor to balance the pull height out. The pull-down distance gets divided by it, so higher values make the pull feel heavier.", + Description = "The factor to balance the pull height out. The pull-down distance gets divided by it, so higher values make the pull feel heavier. Values below 0.1 are treated as 0.1.", + }, + new() + { + Name = "FullWidth", + Type = "bool", + DefaultValue = "false", + Description = "Whether the component takes the whole width of its container instead of shrink-wrapping its anchor.", }, new() { Name = "Loading", Type = "RenderFragment?", DefaultValue = "null", - Description = "The custom loading template to replace the default loading svg.", + Description = "The custom loading template to replace the default loading svg. It is what the indicator shows while the pull is under way and while the refresh is running, so it covers every state that Release and Complete do not take over.", }, new() { @@ -70,6 +93,13 @@ public partial class BitPullToRefreshDemo Description = "The value in pixel to add to the top of pull element as a margin for the pull height.", }, new() + { + Name = "MaxPull", + Type = "int", + DefaultValue = "0", + Description = "The furthest the pull can travel, in pixels, past which it stops following the finger; 0 stops it at Trigger. The indicator holds its full size over that stretch, and only the strip keeps growing. It is measured on the same damped scale as Trigger.", + }, + new() { Name = "OnRefresh", Type = "EventCallback", @@ -90,7 +120,7 @@ public partial class BitPullToRefreshDemo Name = "OnPullMove", Type = "EventCallback", DefaultValue = "", - Description = "The callback for when the pull-down is in progress.", + Description = "The callback for when the pull-down is in progress, reporting the pull height in pixels, which is capped at Trigger - or at MaxPull where the pull is allowed past it. The reports are coalesced to at most one per frame and never repeat a whole pixel.", }, new() { @@ -114,6 +144,20 @@ public partial class BitPullToRefreshDemo Description = "The text that gets announced to screen readers while the refresh is in progress.", }, new() + { + Name = "Release", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to replace the default svg while the pull has passed the trigger and releasing starts the refresh.", + }, + new() + { + Name = "ReleaseLabel", + Type = "string", + DefaultValue = "Release to refresh", + Description = "The text that gets announced to screen readers while the pull has passed the trigger and releasing starts the refresh. An empty string leaves the release state unannounced.", + }, + new() { Name = "ScrollerElement", Type = "ElementReference?", @@ -125,7 +169,7 @@ public partial class BitPullToRefreshDemo Name = "ScrollerSelector", Type = "string?", DefaultValue = "null", - Description = "The CSS selector of the element that is the scroller in the anchor to control the behavior of the pull to refresh.", + Description = "The CSS selector of the element that is the scroller in the anchor to control the behavior of the pull to refresh. It is looked up inside the anchor first and in the document afterwards; left unset, the first element of the anchor is taken as the scroller.", }, new() { @@ -148,12 +192,26 @@ public partial class BitPullToRefreshDemo Name = "Trigger", Type = "int", DefaultValue = "80", - Description = "The pulling height in pixel that triggers the refresh.", + Description = "The pulling height in pixel that triggers the refresh. It is also the distance the indicator grows to its full size over. Values below 1 are treated as 1.", } ]; private readonly List componentPublicMembers = [ + new() + { + Name = "IsRefreshing", + Type = "bool", + DefaultValue = "false", + Description = "Whether a refresh is currently running - the pull was released past the trigger, or RefreshAsync was called, and the OnRefresh callback has not returned yet.", + }, + new() + { + Name = "PullProgress", + Type = "decimal", + DefaultValue = "0", + Description = "How far the current pull has come as a fraction of Trigger: 0 while nothing is being pulled, and 1 once releasing would start a refresh. It reads 1 for the whole of a refresh.", + }, new() { Name = "RefreshAsync", @@ -162,6 +220,121 @@ public partial class BitPullToRefreshDemo }, ]; + private readonly List componentSubEnums = + [ + new() + { + Id = "color-enum", + Name = "BitColor", + Description = "Defines the general colors available in the bit BlazorUI.", + Items = + [ + new() + { + Name= "Primary", + Description="Info Primary general color.", + Value="0", + }, + new() + { + Name= "Secondary", + Description="Secondary general color.", + Value="1", + }, + new() + { + Name= "Tertiary", + Description="Tertiary general color.", + Value="2", + }, + new() + { + Name= "Info", + Description="Info general color.", + Value="3", + }, + new() + { + Name= "Success", + Description="Success general color.", + Value="4", + }, + new() + { + Name= "Warning", + Description="Warning general color.", + Value="5", + }, + new() + { + Name= "SevereWarning", + Description="SevereWarning general color.", + Value="6", + }, + new() + { + Name= "Error", + Description="Error general color.", + Value="7", + }, + new() + { + Name= "PrimaryBackground", + Description="Primary background color.", + Value="8", + }, + new() + { + Name= "SecondaryBackground", + Description="Secondary background color.", + Value="9", + }, + new() + { + Name= "TertiaryBackground", + Description="Tertiary background color.", + Value="10", + }, + new() + { + Name= "PrimaryForeground", + Description="Primary foreground color.", + Value="11", + }, + new() + { + Name= "SecondaryForeground", + Description="Secondary foreground color.", + Value="12", + }, + new() + { + Name= "TertiaryForeground", + Description="Tertiary foreground color.", + Value="13", + }, + new() + { + Name= "PrimaryBorder", + Description="Primary border color.", + Value="14", + }, + new() + { + Name= "SecondaryBorder", + Description="Secondary border color.", + Value="15", + }, + new() + { + Name= "TertiaryBorder", + Description="Tertiary border color.", + Value="16", + } + ] + } + ]; + private readonly List componentSubClasses = [ new() @@ -325,6 +498,7 @@ private async Task HandleOnRefreshDisabled() private double factor = 1.5; private double margin = 30; private double threshold = 0; + private double maxPull = 0; private (int, int)[] behaviorItems = GenerateRandomNumbers(1, 51); private async Task HandleOnRefreshBehavior() { @@ -400,6 +574,30 @@ private async Task HandleOnRefreshStyle() _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); } + private (int, int)[] releaseItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshRelease() + { + await Task.Delay(2000); + releaseItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private (int, int)[] colorItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshColor() + { + await Task.Delay(2000); + colorItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + + private (int, int)[] customColorItems = GenerateRandomNumbers(51, 101); + private async Task HandleOnRefreshCustomColor() + { + await Task.Delay(2000); + customColorItems = GenerateRandomNumbers(51, 101); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + private (int, int)[] classItems = GenerateRandomNumbers(51, 101); private async Task HandleOnRefreshClass() { @@ -408,6 +606,14 @@ private async Task HandleOnRefreshClass() _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); } + private (int, int)[] rtlItems = GenerateRandomNumbers(1, 51); + private async Task HandleOnRefreshRtl() + { + await Task.Delay(2000); + rtlItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); + } + private static (int, int)[] GenerateRandomNumbers(int min, int max) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs index bbfe81b7b30..5ea84f51134 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor.samples.cs @@ -188,7 +188,7 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max)
- +
@foreach (var (idx, i) in advancedItems) { @@ -272,7 +272,8 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max) Trigger=""(int)trigger"" Factor=""(decimal)factor"" Margin=""(int)margin"" - Threshold=""(int)threshold""> + Threshold=""(int)threshold"" + MaxPull=""(int)maxPull"">
@foreach (var (idx, i) in behaviorItems) { @@ -289,6 +290,8 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max)
+
+
"; private readonly string example6CsharpCode = @" @@ -296,6 +299,7 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max) private double factor = 1.5; private double margin = 30; private double threshold = 0; +private double maxPull = 0; private (int, int)[] behaviorItems = GenerateRandomNumbers(1, 51); private async Task HandleOnRefreshBehavior() { @@ -484,6 +488,104 @@ private static (int, int)[] GenerateRandomNumbers(int min, int max) }"; private readonly string example10RazorCode = @" + + + + +
+ @foreach (var (idx, i) in releaseItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + + + + +
"; + private readonly string example10CsharpCode = @" +private (int, int)[] releaseItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshRelease() +{ + await Task.Delay(2000); + releaseItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example11RazorCode = @" + + +
+ +
+ @foreach (var (idx, i) in colorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+ + +
+ @foreach (var (idx, i) in customColorItems) + { +
@(idx.ToString().PadLeft(2, '0')). Item @i
+ } +
+
+
"; + private readonly string example11CsharpCode = @" +private (int, int)[] colorItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshColor() +{ + await Task.Delay(2000); + colorItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private (int, int)[] customColorItems = GenerateRandomNumbers(51, 101); +private async Task HandleOnRefreshCustomColor() +{ + await Task.Delay(2000); + customColorItems = GenerateRandomNumbers(51, 101); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + +private static (int, int)[] GenerateRandomNumbers(int min, int max) +{ + var random = new Random(); + return Enumerable.Range(min, max - min).Select(i => (i, random.Next(min, max))).ToArray(); +}"; + + private readonly string example12RazorCode = @" + + +
+ @foreach (var (idx, i) in rtlItems) + { +
@(idx.ToString().PadLeft(2, '0')) .مورد @i
+ } +
+
"; + private readonly string example13CsharpCode = @" +private (int, int)[] rtlItems = GenerateRandomNumbers(1, 51); +private async Task HandleOnRefreshRtl() +{ + await Task.Delay(2000); + rtlItems = GenerateRandomNumbers(1, 51); + _ = Task.Delay(1000).ContinueWith(_ => InvokeAsync(StateHasChanged)); +} + private static (int, int)[] GenerateRandomNumbers(int min, int max) { var random = new Random(); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs index 0a5f1405fa4..f99f0aa7f8e 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/PullToRefresh/BitPullToRefreshTests.cs @@ -451,7 +451,8 @@ public void BitPullToRefreshShouldPassParametersToJsSetup() Assert.AreEqual(2m, setup.Arguments[6]); Assert.AreEqual(20, setup.Arguments[7]); Assert.AreEqual(10, setup.Arguments[8]); - Assert.AreEqual(false, setup.Arguments[9]); + Assert.AreEqual(0, setup.Arguments[9]); + Assert.AreEqual(false, setup.Arguments[10]); } [TestMethod] @@ -483,11 +484,12 @@ public void BitPullToRefreshShouldCallJsUpdateOnParameterChange() var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); Assert.AreEqual(component.Instance.UniqueId, update.Arguments[0]); - Assert.AreEqual(120, update.Arguments[1]); - Assert.AreEqual(1.5m, update.Arguments[2]); - Assert.AreEqual(30, update.Arguments[3]); - Assert.AreEqual(0, update.Arguments[4]); - Assert.AreEqual(true, update.Arguments[5]); + Assert.AreEqual(120, update.Arguments[3]); + Assert.AreEqual(1.5m, update.Arguments[4]); + Assert.AreEqual(30, update.Arguments[5]); + Assert.AreEqual(0, update.Arguments[6]); + Assert.AreEqual(0, update.Arguments[7]); + Assert.AreEqual(true, update.Arguments[8]); component.Render(parameters => { @@ -511,7 +513,7 @@ public void BitPullToRefreshShouldCallJsUpdateOnIsEnabledChange() }); var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); - Assert.AreEqual(false, update.Arguments[5]); + Assert.AreEqual(false, update.Arguments[8]); } [TestMethod] @@ -710,4 +712,553 @@ public async Task BitPullToRefreshShouldCallJsDisposeOnDispose() var dispose = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.dispose"].Single(); Assert.AreEqual(uniqueId, dispose.Arguments[0]); } + [TestMethod] + public void BitPullToRefreshShouldPassScrollerSelectorToJsSetup() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + RenderComponent(parameters => + { + parameters.Add(p => p.ScrollerSelector, ".scroller"); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(".scroller", setup.Arguments[4]); + } + + [TestMethod] + public void BitPullToRefreshShouldCallJsUpdateOnScrollerSelectorChange() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.update"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScrollerSelector, ".first"); + }); + + component.Render(parameters => + { + parameters.Add(p => p.ScrollerSelector, ".second"); + }); + + var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); + Assert.AreEqual(".second", update.Arguments[2]); + + component.Render(parameters => + { + parameters.Add(p => p.ScrollerSelector, ".second"); + }); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Count); + } + + [TestMethod] + [DataRow(0, 1)] + [DataRow(-10, 1)] + [DataRow(80, 80)] + public void BitPullToRefreshShouldClampTriggerForJs(int trigger, int expected) + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, trigger); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(expected, setup.Arguments[5]); + } + + [TestMethod] + public void BitPullToRefreshShouldClampFactorMarginAndThresholdForJs() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + RenderComponent(parameters => + { + parameters.Add(p => p.Factor, 0m); + parameters.Add(p => p.Margin, -5); + parameters.Add(p => p.Threshold, -5); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(0.1m, setup.Arguments[6]); + Assert.AreEqual(0, setup.Arguments[7]); + Assert.AreEqual(0, setup.Arguments[8]); + } + + [TestMethod] + public void BitPullToRefreshShouldApplyFullWidthClass() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + Assert.IsFalse(component.Find(".bit-ptr").ClassList.Contains("bit-ptr-flw")); + + component.Render(parameters => + { + parameters.Add(p => p.FullWidth, true); + }); + + Assert.IsTrue(component.Find(".bit-ptr").ClassList.Contains("bit-ptr-flw")); + } + + [TestMethod] + [DataRow(BitColor.Primary, "var(--bit-clr-pri)")] + [DataRow(BitColor.Info, "var(--bit-clr-inf)")] + [DataRow(BitColor.Error, "var(--bit-clr-err)")] + [DataRow(BitColor.TertiaryBorder, "var(--bit-clr-brd-ter)")] + public void BitPullToRefreshShouldRespectColor(BitColor color, string expected) + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Color, color); + }); + + StringAssert.Contains(component.Find(".bit-ptr").GetAttribute("style"), $"--bit-ptr-color:{expected}"); + } + + [TestMethod] + public void BitPullToRefreshShouldRespectCustomColorOnlyWhileColorIsUnset() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.CustomColor, "#b400ff"); + }); + + StringAssert.Contains(component.Find(".bit-ptr").GetAttribute("style"), "--bit-ptr-color:#b400ff"); + + component.Render(parameters => + { + parameters.Add(p => p.CustomColor, "#b400ff"); + parameters.Add(p => p.Color, BitColor.Success); + }); + + var style = component.Find(".bit-ptr").GetAttribute("style"); + StringAssert.Contains(style, "--bit-ptr-color:var(--bit-clr-suc)"); + Assert.IsFalse(style!.Contains("#b400ff")); + } + + [TestMethod] + public void BitPullToRefreshShouldNotRenderColorVariableWhenNeitherColorIsSet() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + var style = component.Find(".bit-ptr").GetAttribute("style"); + Assert.IsFalse(style?.Contains("--bit-ptr-color") ?? false); + } + + [TestMethod] + public void BitPullToRefreshShouldRenderReleaseTemplateOnlyPastTheTrigger() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 80); + parameters.Add(p => p.Loading, (RenderFragment)(builder => builder.AddMarkupContent(0, "pulling"))); + parameters.Add(p => p.Release, (RenderFragment)(builder => builder.AddMarkupContent(0, "release"))); + }); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + Assert.AreEqual(1, component.FindAll(".pulling").Count); + Assert.IsEmpty(component.FindAll(".release")); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + Assert.IsEmpty(component.FindAll(".pulling")); + Assert.AreEqual(1, component.FindAll(".release").Count); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + Assert.AreEqual(1, component.FindAll(".pulling").Count); + Assert.IsEmpty(component.FindAll(".release")); + } + + [TestMethod] + public void BitPullToRefreshShouldFallBackToLoadingTemplateWithoutAReleaseTemplate() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Loading, (RenderFragment)(builder => builder.AddMarkupContent(0, "pulling"))); + }); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + + Assert.AreEqual(1, component.FindAll(".pulling").Count); + Assert.IsTrue(component.Find(".bit-ptr-spw").ClassList.Contains("bit-ptr-crl")); + } + + [TestMethod] + public void BitPullToRefreshShouldNotRenderReleaseTemplateWhileRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Release, (RenderFragment)(builder => builder.AddMarkupContent(0, "release"))); + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + + Assert.IsEmpty(component.FindAll(".release")); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + } + + [TestMethod] + public void BitPullToRefreshShouldAnnounceReleaseLabelPastTheTrigger() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + Assert.AreEqual(string.Empty, component.Find(".bit-ptr-vhd").TextContent); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + Assert.AreEqual(string.Empty, component.Find(".bit-ptr-vhd").TextContent); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + Assert.AreEqual("Release to refresh", component.Find(".bit-ptr-vhd").TextContent); + + component.Instance._OnCancel(80m).GetAwaiter().GetResult(); + Assert.AreEqual(string.Empty, component.Find(".bit-ptr-vhd").TextContent); + } + + [TestMethod] + public void BitPullToRefreshShouldAnnounceCustomReleaseLabel() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ReleaseLabel, "Let go"); + }); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + + Assert.AreEqual("Let go", component.Find(".bit-ptr-vhd").TextContent); + } + + [TestMethod] + public void BitPullToRefreshShouldLeaveTheReleaseStateSilentWithAnEmptyLabel() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ReleaseLabel, string.Empty); + }); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + + Assert.AreEqual(string.Empty, component.Find(".bit-ptr-vhd").TextContent); + Assert.IsTrue(component.Find(".bit-ptr-spw").ClassList.Contains("bit-ptr-crl")); + } + + [TestMethod] + public void BitPullToRefreshShouldMarkTheRootBusyWhileRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + Assert.IsNull(component.Find(".bit-ptr").GetAttribute("aria-busy")); + + var refreshTask = component.Instance._Refresh(); + Assert.AreEqual("true", component.Find(".bit-ptr").GetAttribute("aria-busy")); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + + Assert.IsNull(component.Find(".bit-ptr").GetAttribute("aria-busy")); + } + + [TestMethod] + public void BitPullToRefreshShouldHideTheDefaultGlyphsFromAssistiveTechnology() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + var svg = component.Find(".bit-ptr-spn svg"); + Assert.AreEqual("true", svg.GetAttribute("aria-hidden")); + Assert.AreEqual("false", svg.GetAttribute("focusable")); + } + + [TestMethod] + public void BitPullToRefreshShouldReportIsRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + Assert.IsFalse(component.Instance.IsRefreshing); + + var refreshTask = component.Instance._Refresh(); + Assert.IsTrue(component.Instance.IsRefreshing); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + + Assert.IsFalse(component.Instance.IsRefreshing); + } + + [TestMethod] + public void BitPullToRefreshShouldReportPullProgress() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 100); + }); + + Assert.AreEqual(0m, component.Instance.PullProgress); + + component.Instance._OnMove(25m).GetAwaiter().GetResult(); + Assert.AreEqual(0.25m, component.Instance.PullProgress); + + component.Instance._OnMove(100m).GetAwaiter().GetResult(); + Assert.AreEqual(1m, component.Instance.PullProgress); + + component.Instance._OnCancel(100m).GetAwaiter().GetResult(); + Assert.AreEqual(0m, component.Instance.PullProgress); + } + + [TestMethod] + public void BitPullToRefreshShouldReportFullPullProgressWhileRefreshing() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var tcs = new TaskCompletionSource(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnRefresh, EventCallback.Factory.Create(this, () => tcs.Task)); + }); + + var refreshTask = component.Instance._Refresh(); + Assert.AreEqual(1m, component.Instance.PullProgress); + + tcs.SetResult(); + refreshTask.GetAwaiter().GetResult(); + } + + [TestMethod] + public void BitPullToRefreshShouldNotDivideByZeroWithAZeroFactorOrNegativeSizes() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 0); + parameters.Add(p => p.Factor, 0m); + }); + + component.Instance._OnMove(10m).GetAwaiter().GetResult(); + + var spinnerWrapper = component.Find(".bit-ptr-spw"); + StringAssert.Contains(spinnerWrapper.GetAttribute("style"), "width:35px"); + Assert.AreEqual(1m, component.Instance.PullProgress); + } + + [TestMethod] + public void BitPullToRefreshShouldSkipRenderingForAMoveThatDrawsTheSame() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + var renderCount = component.RenderCount; + + // The same whole pixel and the same release state: nothing about the indicator would be drawn + // differently, so re-rendering the component - and the whole anchor with it - is skipped. + component.Instance._OnMove(40.2m).GetAwaiter().GetResult(); + Assert.AreEqual(renderCount, component.RenderCount); + + component.Instance._OnMove(41.6m).GetAwaiter().GetResult(); + Assert.IsGreaterThan(renderCount, component.RenderCount); + } + + [TestMethod] + public void BitPullToRefreshShouldStillReportEveryMoveToTheCallback() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var moves = new System.Collections.Generic.List(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnPullMove, EventCallback.Factory.Create(this, diff => moves.Add(diff))); + }); + + component.Instance._OnMove(40m).GetAwaiter().GetResult(); + component.Instance._OnMove(40.2m).GetAwaiter().GetResult(); + component.Instance._OnMove(40.4m).GetAwaiter().GetResult(); + + CollectionAssert.AreEqual(new[] { 40m, 40.2m, 40.4m }, moves); + } + + [TestMethod] + public void BitPullToRefreshShouldRenderTheAnchorAliasLikeChildContent() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, (RenderFragment)(builder => builder.AddMarkupContent(0, "
content
"))); + }); + + Assert.AreEqual(1, component.FindAll(".anchored").Count); + } + + [TestMethod] + public void BitPullToRefreshShouldPreferAnchorOverChildContent() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Anchor, (RenderFragment)(builder => builder.AddMarkupContent(0, "
anchor
"))); + parameters.Add(p => p.ChildContent, (RenderFragment)(builder => builder.AddMarkupContent(0, "
child
"))); + }); + + Assert.AreEqual(1, component.FindAll(".anchored").Count); + Assert.IsEmpty(component.FindAll(".childed")); + } + + [TestMethod] + public void BitPullToRefreshShouldDropThePullHeightWhenItGetsDisabledWhileIdle() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.update"); + + var component = RenderComponent(); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "width:35px"); + + component.Render(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "width:0px"); + Assert.IsFalse(component.Find(".bit-ptr-spw").ClassList.Contains("bit-ptr-crl")); + } + [TestMethod] + public void BitPullToRefreshShouldPassMaxPullToJsSetup() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + RenderComponent(parameters => + { + parameters.Add(p => p.MaxPull, 120); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(120, setup.Arguments[9]); + } + + [TestMethod] + public void BitPullToRefreshShouldClampNegativeMaxPullForJs() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + RenderComponent(parameters => + { + parameters.Add(p => p.MaxPull, -40); + }); + + var setup = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.setup"].Single(); + Assert.AreEqual(0, setup.Arguments[9]); + } + + [TestMethod] + public void BitPullToRefreshShouldCallJsUpdateOnMaxPullChange() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.update"); + + var component = RenderComponent(); + + component.Render(parameters => + { + parameters.Add(p => p.MaxPull, 110); + }); + + var update = Context.JSInterop.Invocations["BitBlazorUI.PullToRefresh.update"].Single(); + Assert.AreEqual(110, update.Arguments[7]); + } + + [TestMethod] + public void BitPullToRefreshShouldHoldTheIndicatorAtFullSizeThroughAnOverpull() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 80); + parameters.Add(p => p.MaxPull, 120); + }); + + component.Instance._OnMove(80m).GetAwaiter().GetResult(); + var atTrigger = component.Find(".bit-ptr-spw").GetAttribute("style"); + StringAssert.Contains(atTrigger, "width:35px"); + StringAssert.Contains(component.Find(".bit-ptr-spn").GetAttribute("style"), "width:24px"); + StringAssert.Contains(component.Find(".bit-ptr-spn").GetAttribute("style"), "rotate(0deg)"); + + // Past the trigger only the strip keeps growing: the disc, the glyph and the rotation are all held + // where the trigger left them, and the release state stays on. + component.Instance._OnMove(120m).GetAwaiter().GetResult(); + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "width:35px"); + StringAssert.Contains(component.Find(".bit-ptr-spn").GetAttribute("style"), "width:24px"); + StringAssert.Contains(component.Find(".bit-ptr-spn").GetAttribute("style"), "rotate(0deg)"); + Assert.IsTrue(component.Find(".bit-ptr-spw").ClassList.Contains("bit-ptr-crl")); + Assert.AreEqual(1m, component.Instance.PullProgress); + } + + [TestMethod] + public void BitPullToRefreshShouldStillRefreshAfterAnOverpull() + { + Context.JSInterop.SetupVoid("BitBlazorUI.PullToRefresh.setup"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Trigger, 80); + parameters.Add(p => p.MaxPull, 120); + }); + + component.Instance._OnMove(120m).GetAwaiter().GetResult(); + + // Halfway through the overpull the indicator is drawn lower, since it follows the strip down. + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "margin-top:60px"); + + component.Instance._OnEnd(120m).GetAwaiter().GetResult(); + + // A release past the trigger is still a release: the pull is settled at the trigger, where the refresh + // js is about to ask for holds it, rather than being dropped the way a short pull is. + Assert.IsTrue(component.Find(".bit-ptr-spw").ClassList.Contains("bit-ptr-crl")); + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "margin-top:40px"); + StringAssert.Contains(component.Find(".bit-ptr-spw").GetAttribute("style"), "width:35px"); + Assert.AreEqual(1m, component.Instance.PullProgress); + } } From 7a2e8476a509ad6a59c30f93dfcaf28fa1c922b0 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Thu, 3 Sep 2026 13:56:48 +0330 Subject: [PATCH 4/4] fix demo --- .../PullToRefresh/BitPullToRefreshDemo.razor | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor index 300b36da509..7bee3c651a4 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/PullToRefresh/BitPullToRefreshDemo.razor @@ -267,7 +267,7 @@
Styles and Classes reach every part of the component - the strip, the indicator's disc and the glyph inside it - and each of those three carries its own release, refreshing and complete variant on top, so one state can be styled without restyling the rest. Pull far enough to see the release variant take over.
-

+
@@ -294,14 +294,16 @@
A pull is a vertical gesture, so Dir leaves it exactly as it is; what the direction changes is the content of the anchor and of any custom template, and the loading strip, which is pinned to the start edge and so follows the writing direction across the anchor.

- -
- @foreach (var (idx, i) in rtlItems) - { -
@(idx.ToString().PadLeft(2, '0')) .مورد @i
- } -
-
+
+ +
+ @foreach (var (idx, i) in rtlItems) + { +
@(idx.ToString().PadLeft(2, '0')) .مورد @i
+ } +
+
+