using System.Globalization; using Connected.Annotations; using Connected.Extensions; using Connected.Interop; using Connected.Services; using Connected.Utilities; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; namespace Connected.Components; public partial class Tabs : UIComponent, IAsyncDisposable { private bool _isDisposed; private int _activePanelIndex = 0; private int _scrollIndex = 0; private bool _isRendered = false; private bool _prevButtonDisabled; private bool _nextButtonDisabled; private bool _showScrollButtons; private bool _disableSliderAnimation; private ElementReference _tabsContentSize; private double _size; private double _position; private double _toolbarContentSize; private double _allTabsSize; private double _scrollPosition; private IResizeObserver _resizeObserver; [CascadingParameter(Name = "RightToLeft")] public bool RightToLeft { get; set; } [Inject] private IResizeObserverFactory _resizeObserverFactory { get; set; } /// /// If true, render all tabs and hide (display:none) every non-active. /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public bool KeepPanelsAlive { get; set; } = false; /// /// If true, sets the border-radius to theme default. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool Rounded { get; set; } /// /// If true, sets a border between the content and the toolbar depending on the position. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool Border { get; set; } /// /// If true, toolbar will be outlined. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool Outlined { get; set; } /// /// If true, centers the tabitems. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool Centered { get; set; } /// /// Hides the active tab slider. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool HideSlider { get; set; } /// /// Icon to use for left pagination. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public string PrevIcon { get; set; } = Icons.Filled.ChevronLeft; /// /// Icon to use for right pagination. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public string NextIcon { get; set; } = Icons.Filled.ChevronRight; /// /// If true, always display the scroll buttons even if the tabs are smaller than the required with, buttons will be disabled if there is nothing to scroll. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool AlwaysShowScrollButtons { get; set; } /// /// Sets the maxheight the component can have. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public int? MaxHeight { get; set; } = null; /// /// Sets the position of the tabs itself. /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public Position Position { get; set; } = Position.Top; /// /// The color of the component. It supports the theme colors. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public ThemeColor Color { get; set; } = ThemeColor.Default; /// /// The color of the tab slider. It supports the theme colors. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public ThemeColor SliderColor { get; set; } = ThemeColor.Inherit; /// /// The color of the icon. It supports the theme colors. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public ThemeColor IconColor { get; set; } = ThemeColor.Inherit; /// /// The color of the next/prev icons. It supports the theme colors. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public ThemeColor ScrollIconColor { get; set; } = ThemeColor.Inherit; /// /// The higher the number, the heavier the drop-shadow, applies around the whole component. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public int Elevation { set; get; } = 0; /// /// If true, will apply elevation, rounded, outlined effects to the whole tab component instead of just toolbar. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool ApplyEffectsToContainer { get; set; } /// /// If true, disables ripple effect. /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool DisableRipple { get; set; } /// /// If true, disables slider animation /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public bool DisableSliderAnimation { get => _disableSliderAnimation; set => _disableSliderAnimation = value; } /// /// Child content of component. /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public RenderFragment ChildContent { get; set; } /// /// This fragment is placed between toolbar and panels. /// It can be used to display additional content like an address line in a browser. /// The active tab will be the content of this RenderFragement /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public RenderFragment PrePanelContent { get; set; } /// /// Custom class/classes for TabPanel /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public string TabPanelClass { get; set; } /// /// Custom class/classes for Selected Content Panel /// [Parameter] [Category(CategoryTypes.Tabs.Appearance)] public string PanelClass { get; set; } public TabPanel ActivePanel { get; private set; } /// /// The current active panel index. Also with Bidirectional Binding /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public int ActivePanelIndex { get => _activePanelIndex; set { if (_activePanelIndex != value) { _activePanelIndex = value; if (_isRendered) ActivePanel = _panels[_activePanelIndex]; ActivePanelIndexChanged.InvokeAsync(value); } } } /// /// Fired when ActivePanelIndex changes. /// [Parameter] public EventCallback ActivePanelIndexChanged { get; set; } /// /// A readonly list of the current panels. Panels should be added or removed through the RenderTree use this collection to get informations about the current panels /// public IReadOnlyList Panels { get; private set; } private List _panels; /// /// A render fragment that is added before or after (based on the value of HeaderPosition) the tabs inside the header panel of the tab control /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public RenderFragment Header { get; set; } /// /// Additional content specified by Header is placed either before the tabs, after or not at all /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public TabHeaderPosition HeaderPosition { get; set; } = TabHeaderPosition.After; /// /// A render fragment that is added before or after (based on the value of HeaderPosition) inside each tab panel /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public RenderFragment TabPanelHeader { get; set; } /// /// Additional content specified by Header is placed either before the tabs, after or not at all /// [Parameter] [Category(CategoryTypes.Tabs.Behavior)] public TabHeaderPosition TabPanelHeaderPosition { get; set; } = TabHeaderPosition.After; /// /// Can be used in derived class to add a class to the main container. If not overwritten return an empty string /// protected virtual string InternalClassName { get; } = string.Empty; private string _prevIcon; private string _nextIcon; #region Life cycle management public Tabs() { _panels = new List(); Panels = _panels.AsReadOnly(); } protected override void OnInitialized() { _resizeObserver = _resizeObserverFactory.Create(); base.OnInitialized(); } protected override void OnParametersSet() { if (_resizeObserver == null) { _resizeObserver = _resizeObserverFactory.Create(); } Rerender(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { var items = _panels.Select(x => x.PanelRef).ToList(); items.Add(_tabsContentSize); if (_panels.Count > 0) ActivePanel = _panels[_activePanelIndex]; await _resizeObserver.Observe(items); _resizeObserver.OnResized += OnResized; Rerender(); StateHasChanged(); _isRendered = true; } } public async ValueTask DisposeAsync() { if (_isDisposed == true) return; _isDisposed = true; _resizeObserver.OnResized -= OnResized; await _resizeObserver.DisposeAsync(); } #endregion #region Children internal void AddPanel(TabPanel tabPanel) { _panels.Add(tabPanel); if (_panels.Count == 1) ActivePanel = tabPanel; StateHasChanged(); } internal async Task SetPanelRef(ElementReference reference) { if (_isRendered == true && _resizeObserver.IsElementObserved(reference) == false) { await _resizeObserver.Observe(reference); Rerender(); StateHasChanged(); } } internal async Task RemovePanel(TabPanel tabPanel) { if (_isDisposed) return; var index = _panels.IndexOf(tabPanel); var newIndex = index; if (ActivePanelIndex == index && index == _panels.Count - 1) { newIndex = index > 0 ? index - 1 : 0; if (_panels.Count == 1) { ActivePanel = null; } } else if (_activePanelIndex > index) { _activePanelIndex--; await ActivePanelIndexChanged.InvokeAsync(_activePanelIndex); } if (index != newIndex) { ActivePanelIndex = newIndex; } _panels.Remove(tabPanel); await _resizeObserver.Unobserve(tabPanel.PanelRef); Rerender(); StateHasChanged(); } public void ActivatePanel(TabPanel panel, bool ignoreDisabledState = false) { ActivatePanel(panel, null, ignoreDisabledState); } public void ActivatePanel(int index, bool ignoreDisabledState = false) { var panel = _panels[index]; ActivatePanel(panel, null, ignoreDisabledState); } public void ActivatePanel(object id, bool ignoreDisabledState = false) { var panel = _panels.Where((p) => Equals(p.ID, id)).FirstOrDefault(); if (panel != null) ActivatePanel(panel, null, ignoreDisabledState); } private void ActivatePanel(TabPanel panel, MouseEventArgs ev, bool ignoreDisabledState = false) { if (!panel.Disabled || ignoreDisabledState) { ActivePanelIndex = _panels.IndexOf(panel); if (ev != null) ActivePanel.OnClick.InvokeAsync(ev); CenterScrollPositionAroundSelectedItem(); SetSliderState(); SetScrollabilityStates(); StateHasChanged(); } } #endregion #region Style and classes protected string TabsClassnames => new CssBuilder("mud-tabs") .AddClass($"mud-tabs-rounded", ApplyEffectsToContainer && Rounded) .AddClass($"mud-paper-outlined", ApplyEffectsToContainer && Outlined) .AddClass($"mud-elevation-{Elevation}", ApplyEffectsToContainer && Elevation != 0) .AddClass($"mud-tabs-reverse", Position == Position.Bottom) .AddClass($"mud-tabs-vertical", IsVerticalTabs()) .AddClass($"mud-tabs-vertical-reverse", Position == Position.Right && !RightToLeft || (Position == Position.Left) && RightToLeft || Position == Position.End) .AddClass(InternalClassName) .AddClass(Class) .Build(); protected string ToolbarClassnames => new CssBuilder("mud-tabs-toolbar") .AddClass($"mud-tabs-rounded", !ApplyEffectsToContainer && Rounded) .AddClass($"mud-tabs-vertical", IsVerticalTabs()) .AddClass($"mud-tabs-toolbar-{Color.ToDescriptionString()}", Color != ThemeColor.Default) .AddClass($"mud-tabs-border-{ConvertPosition(Position).ToDescriptionString()}", Border) .AddClass($"mud-paper-outlined", !ApplyEffectsToContainer && Outlined) .AddClass($"mud-elevation-{Elevation}", !ApplyEffectsToContainer && Elevation != 0) .Build(); protected string WrapperClassnames => new CssBuilder("mud-tabs-toolbar-wrapper") .AddClass($"mud-tabs-centered", Centered) .AddClass($"mud-tabs-vertical", IsVerticalTabs()) .Build(); protected string WrapperScrollStyle => new StyleBuilder() .AddStyle("transform", $"translateX({(-1 * _scrollPosition).ToString(CultureInfo.InvariantCulture)}px)", Position is Position.Top or Position.Bottom) .AddStyle("transform", $"translateY({(-1 * _scrollPosition).ToString(CultureInfo.InvariantCulture)}px)", IsVerticalTabs()) .Build(); protected string PanelsClassnames => new CssBuilder("mud-tabs-panels") .AddClass($"mud-tabs-vertical", IsVerticalTabs()) .AddClass(PanelClass) .Build(); protected string SliderClass => new CssBuilder("mud-tab-slider") .AddClass($"mud-{SliderColor.ToDescriptionString()}", SliderColor != ThemeColor.Inherit) .AddClass($"mud-tab-slider-horizontal", Position is Position.Top or Position.Bottom) .AddClass($"mud-tab-slider-vertical", IsVerticalTabs()) .AddClass($"mud-tab-slider-horizontal-reverse", Position == Position.Bottom) .AddClass($"mud-tab-slider-vertical-reverse", Position == Position.Right || Position == Position.Start && RightToLeft || Position == Position.End && !RightToLeft) .Build(); protected string MaxHeightStyles => new StyleBuilder() .AddStyle("max-height", MaxHeight.ToPx(), MaxHeight != null) .Build(); protected string SliderStyle => RightToLeft ? new StyleBuilder() .AddStyle("width", _size.ToPx(), Position is Position.Top or Position.Bottom) .AddStyle("right", _position.ToPx(), Position is Position.Top or Position.Bottom) .AddStyle("transition", _disableSliderAnimation ? "none" : "right .3s cubic-bezier(.64,.09,.08,1);", Position is Position.Top or Position.Bottom) .AddStyle("transition", _disableSliderAnimation ? "none" : "top .3s cubic-bezier(.64,.09,.08,1);", IsVerticalTabs()) .AddStyle("height", _size.ToPx(), IsVerticalTabs()) .AddStyle("top", _position.ToPx(), IsVerticalTabs()) .Build() : new StyleBuilder() .AddStyle("width", _size.ToPx(), Position is Position.Top or Position.Bottom) .AddStyle("left", _position.ToPx(), Position is Position.Top or Position.Bottom) .AddStyle("transition", _disableSliderAnimation ? "none" : "left .3s cubic-bezier(.64,.09,.08,1);", Position is Position.Top or Position.Bottom) .AddStyle("transition", _disableSliderAnimation ? "none" : "top .3s cubic-bezier(.64,.09,.08,1);", IsVerticalTabs()) .AddStyle("height", _size.ToPx(), IsVerticalTabs()) .AddStyle("top", _position.ToPx(), IsVerticalTabs()) .Build(); private bool IsVerticalTabs() { return Position is Position.Left or Position.Right or Position.Start or Position.End; } private Position ConvertPosition(Position position) { return position switch { Position.Start => RightToLeft ? Position.Right : Position.Left, Position.End => RightToLeft ? Position.Left : Position.Right, _ => position }; } string GetTabClass(TabPanel panel) { var tabClass = new CssBuilder("mud-tab") .AddClass($"mud-tab-active", when: () => panel == ActivePanel) .AddClass($"mud-disabled", panel.Disabled) .AddClass($"mud-ripple", !DisableRipple) .AddClass(TabPanelClass) .AddClass(panel.Class) .Build(); return tabClass; } private Placement GetTooltipPlacement() { if (Position == Position.Right) return Placement.Left; else if (Position == Position.Left) return Placement.Right; else if (Position == Position.Bottom) return Placement.Top; else return Placement.Bottom; } string GetTabStyle(TabPanel panel) { var tabStyle = new StyleBuilder() .AddStyle(panel.Style) .Build(); return tabStyle; } #endregion #region Rendering and placement private void Rerender() { _nextIcon = RightToLeft ? PrevIcon : NextIcon; _prevIcon = RightToLeft ? NextIcon : PrevIcon; GetToolbarContentSize(); GetAllTabsSize(); SetScrollButtonVisibility(); SetSliderState(); SetScrollabilityStates(); } private async void OnResized(IDictionary changes) { Rerender(); await InvokeAsync(StateHasChanged); } private void SetSliderState() { if (ActivePanel == null) { return; } _position = GetLengthOfPanelItems(ActivePanel); _size = GetRelevantSize(ActivePanel.PanelRef); } private void GetToolbarContentSize() => _toolbarContentSize = GetRelevantSize(_tabsContentSize); private void GetAllTabsSize() { double totalTabsSize = 0; foreach (var panel in _panels) { totalTabsSize += GetRelevantSize(panel.PanelRef); } _allTabsSize = totalTabsSize; } private double GetRelevantSize(ElementReference reference) => Position switch { Position.Top or Position.Bottom => _resizeObserver.GetWidth(reference), _ => _resizeObserver.GetHeight(reference) }; private double GetLengthOfPanelItems(TabPanel panel, bool inclusive = false) { var value = 0.0; foreach (var item in _panels) { if (item == panel) { if (inclusive) { value += GetRelevantSize(item.PanelRef); } break; } value += GetRelevantSize(item.PanelRef); } return value; } private double GetPanelLength(TabPanel panel) => panel == null ? 0.0 : GetRelevantSize(panel.PanelRef); #endregion #region scrolling private void SetScrollButtonVisibility() { _showScrollButtons = AlwaysShowScrollButtons || _allTabsSize > _toolbarContentSize; } private void ScrollPrev() { _scrollIndex = Math.Max(_scrollIndex - GetVisiblePanels(), 0); ScrollToItem(_panels[_scrollIndex]); SetScrollabilityStates(); } private void ScrollNext() { _scrollIndex = Math.Min(_scrollIndex + GetVisiblePanels(), _panels.Count - 1); ScrollToItem(_panels[_scrollIndex]); SetScrollabilityStates(); } /// /// Calculates the amount of panels that are completely visible inside the toolbar content area. Panels that are just partially visible are not considered here! /// /// The amount of panels visible inside the toolbar area. CAUTION: Might return 0! private int GetVisiblePanels() { var x = 0D; var count = 0; var toolbarContentSize = GetRelevantSize(_tabsContentSize); foreach (var panel in _panels) { x += GetRelevantSize(panel.PanelRef); if (x < toolbarContentSize) { count++; } else { break; } } return count; } private void ScrollToItem(TabPanel panel) { var position = GetLengthOfPanelItems(panel); _scrollPosition = RightToLeft ? -position : position; } private bool IsAfterLastPanelIndex(int index) => index >= _panels.Count; private bool IsBeforeFirstPanelIndex(int index) => index < 0; private void CenterScrollPositionAroundSelectedItem() { TabPanel panelToStart = ActivePanel; var length = GetPanelLength(panelToStart); if (length >= _toolbarContentSize) { ScrollToItem(panelToStart); return; } var indexCorrection = 1; while (true) { var panelAfterIndex = _activePanelIndex + indexCorrection; if (!IsAfterLastPanelIndex(panelAfterIndex)) { length += GetPanelLength(_panels[panelAfterIndex]); } if (length >= _toolbarContentSize) { _scrollIndex = _panels.IndexOf(panelToStart); ScrollToItem(panelToStart); break; } length = _toolbarContentSize - length; var panelBeforeindex = _activePanelIndex - indexCorrection; if (!IsBeforeFirstPanelIndex(panelBeforeindex)) { length -= GetPanelLength(_panels[panelBeforeindex]); } else { break; } if (length < 0) { _scrollIndex = _panels.IndexOf(panelToStart); ScrollToItem(panelToStart); break; } length = _toolbarContentSize - length; panelToStart = _panels[_activePanelIndex - indexCorrection]; indexCorrection++; } _scrollIndex = _panels.IndexOf(panelToStart); ScrollToItem(panelToStart); SetScrollabilityStates(); } private void SetScrollabilityStates() { var isEnoughSpace = _allTabsSize <= _toolbarContentSize; if (isEnoughSpace) { _nextButtonDisabled = true; _prevButtonDisabled = true; } else { // Disable next button if the last panel is completely visible _nextButtonDisabled = Math.Abs(_scrollPosition) >= GetLengthOfPanelItems(_panels.Last(), true) - _toolbarContentSize; _prevButtonDisabled = _scrollIndex == 0; } } #endregion }