diff --git a/docs/README.md b/docs/README.md index a935133..56c1cf1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,14 @@ Complete card system for organizing content. - Modular components (header, body, footer, etc.) - Support for images and icons +#### [Dropdown](components/dropdown.md) +Collapsible dropdown menu anchored to an activator button. +- Four opening directions (down, up, start, end) +- Dark menu color theme and full-width mode +- Custom activator template with full keyboard accessibility +- Active, disabled, and large-size item variants +- No JavaScript interop required + #### [Modal](components/modal.md) Modal dialog component for displaying content in an overlay. - Accessible dialog overlay diff --git a/docs/components/dropdown.md b/docs/components/dropdown.md new file mode 100644 index 0000000..faa570a --- /dev/null +++ b/docs/components/dropdown.md @@ -0,0 +1,325 @@ +# BitDropdown + +The `BitDropdown` component provides a [dropdown menu using Bootstrap Italia styles](https://italia.github.io/bootstrap-italia/docs/componenti/dropdown/). + +## Namespace + +```csharp +BitBlazor.Components +``` + +## Description + +`BitDropdown` renders a collapsible menu anchored to an activator button. Opening, closing, and full keyboard navigation are managed entirely in Blazor — no JavaScript interop is required. The menu can be positioned in four directions, styled with a dark color theme, and expanded to full container width. Individual `BitDropdownItem` components support navigation links, click callbacks, active highlighting, and disabled states. + +A custom `ActivatorTemplate` can replace the built-in toggle button with any element while retaining the complete keyboard interaction model. + +## Components + +| Component | Description | +|-----------|-------------| +| `BitDropdown` | Root container. Renders the activator and the collapsible menu; orchestrates open/close state and keyboard focus | +| `BitDropdownItem` | A single menu entry rendered as a `
  • ` pair inside the menu | + +## BitDropdown Parameters + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `ChildContent` | `RenderFragment` | ✓ | - | One or more `BitDropdownItem` components rendered inside the dropdown menu | +| `ActivatorId` | `string` | ✓ | - | Unique `id` for the activator element. Used to link the button to the menu via `aria-labelledby` | +| `ActivatorLabel` | `string` | ✗ | `""` | Text label shown on the default activator button | +| `ActivatorTemplate` | `RenderFragment?` | ✗ | `null` | Custom render fragment that replaces the default activator button. Receives an `ActivatorContext` context object | +| `Position` | `DropdownPosition` | ✗ | `DropdownPosition.Down` | Direction in which the menu opens relative to the activator | +| `MenuColor` | `DropdownMenuColor` | ✗ | `DropdownMenuColor.Default` | Color theme applied to the dropdown menu | +| `MenuWidth` | `DropdownMenuWidth` | ✗ | `DropdownMenuWidth.Default` | Width behaviour of the dropdown menu | +| `ItemSize` | `DropdownItemSize` | ✗ | `DropdownItemSize.Default` | Visual size applied to all child `BitDropdownItem` components | +| `MenuHeaderTemplate` | `RenderFragment?` | ✗ | `null` | Optional content rendered as a heading above the item list | +| `Id` | `string?` | ✗ | `null` | Sets the `id` HTML attribute on the root container element | +| `CssClass` | `string?` | ✗ | `null` | Additional CSS classes applied to the root container element | +| `AdditionalAttributes` | `IDictionary?` | ✗ | - | Additional HTML attributes forwarded to the root container element | + +## BitDropdownItem Parameters + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `ChildContent` | `RenderFragment` | ✓ | - | The content rendered inside the item link | +| `Href` | `string?` | ✗ | `null` | URL the item navigates to when clicked. In SSR rendering, the browser follows this URL. In interactive rendering, used as a navigation fallback when `OnClick` has no delegate | +| `OnClick` | `EventCallback` | ✗ | - | Callback invoked when the item is clicked. Takes precedence over `Href` navigation in interactive rendering | +| `Active` | `bool` | ✗ | `false` | When `true`, applies the active style to the item | +| `Disabled` | `bool` | ✗ | `false` | When `true`, disables the item and adds `aria-disabled="true"` | +| `AdditionalAttributes` | `IDictionary?` | ✗ | - | Additional HTML attributes forwarded to the item anchor element | + +## ActivatorContext + +When you provide a custom `ActivatorTemplate`, Blazor passes an `ActivatorContext` object as the template context. Use its members to wire the correct behaviour and ARIA attributes onto your activator element. + +| Member | Type | Description | +|--------|------|-------------| +| `ActivatorId` | `string` | The `id` value from `BitDropdown.ActivatorId`. Bind to your element's `id` attribute | +| `ActivatorLabel` | `string` | The label text from `BitDropdown.ActivatorLabel` | +| `ActivatorRef` | `ElementReference` | Optional element reference. Bind via `@ref` to enable focus restoration when the menu closes via Escape | +| `Attributes` | `IDictionary` | Pre-populated with `aria-haspopup="true"` and a live `aria-expanded` value. Spread onto your activator via `@attributes` | +| `ToggleDropdown()` | `void` | Call on `@onclick` to open or close the menu | +| `HandleKeyDownAsync(KeyboardEventArgs)` | `Task` | Call on `@onkeydown` to enable full keyboard navigation (Enter, Space, ArrowDown, ArrowUp) | + +## Used Enumerations + +### DropdownPosition + +| Value | Description | +|-------|-------------| +| `Down` | Menu opens below the activator (default) | +| `Up` | Menu opens above the activator | +| `End` | Menu opens to the right of the activator | +| `Start` | Menu opens to the left of the activator | + +### DropdownMenuColor + +| Value | Description | +|-------|-------------| +| `Default` | Standard light menu background | +| `Dark` | Dark-themed menu background | + +### DropdownMenuWidth + +| Value | Description | +|-------|-------------| +| `Default` | Width determined by the longest item | +| `Full` | Menu expands to the full width of the parent container | + +### DropdownItemSize + +| Value | Description | +|-------|-------------| +| `Default` | Standard item height | +| `Large` | Increased item height | + +## Accessibility + +`BitDropdown` implements the WAI-ARIA [Disclosure Navigation Menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) and follows WCAG 2.2 AA. + +### Automatic ARIA wiring + +The default activator button always carries: + +| Attribute | Value | Effect | +|-----------|-------|--------| +| `aria-haspopup` | `"true"` | Announces the presence of a popup to screen readers | +| `aria-expanded` | `"false"` / `"true"` | Updated automatically on every open/close cycle | +| `id` | value of `ActivatorId` | Linked to the menu via `aria-labelledby` | + +Disabled `BitDropdownItem` components receive `aria-disabled="true"` automatically and are skipped by all keyboard-focus algorithms. + +### Keyboard interaction + +| Key | Behaviour | +|-----|-----------| +| `Enter` / `Space` | Toggle the menu open or closed; focus stays on the activator | +| `ArrowDown` | Open the menu and move focus to the **first** enabled item | +| `ArrowUp` | Open the menu and move focus to the **last** enabled item | +| `ArrowDown` (when open, focus inside menu) | Move focus to the **next** enabled item (wraps to first) | +| `ArrowUp` (when open, focus inside menu) | Move focus to the **previous** enabled item (wraps to last) | +| `Enter` / `Space` (focus on item) | Activate the item (invoke `OnClick` or navigate to `Href`) | +| `Escape` (focus inside menu) | Close the menu and return focus to the activator | + +### Custom activator template + +When replacing the default button, you must wire all five directives to preserve full keyboard accessibility and correct ARIA state: + +```razor + + + + + Profile + Log out + +``` + +| Directive | Purpose | +|-----------|---------| +| `id="@ctx.ActivatorId"` | Links the button to the menu via `aria-labelledby` | +| `@ref="ctx.ActivatorRef"` | Optional. Enables focus restoration to the activator when the menu closes via Escape | +| `@onclick="ctx.ToggleDropdown"` | Handles mouse and touch toggle | +| `@onkeydown="ctx.HandleKeyDownAsync"` | Handles keyboard toggle and arrow-key navigation | +| `@attributes="ctx.Attributes"` | Spreads `aria-haspopup` and live `aria-expanded` | + +> **Note:** Omitting `@ref="ctx.ActivatorRef"` is safe — `CloseAsync` degrades gracefully and simply will not restore focus to the activator on Escape. + +## Usage Examples + +### Basic dropdown + +```razor + + New item + Edit + Delete + +``` + +### With click callbacks + +```razor + + New + Edit + Delete + + +@code { + private void HandleNew() { /* ... */ } + private void HandleEdit() { /* ... */ } + private void HandleDelete() { /* ... */ } +} +``` + +### With active and disabled items + +```razor + + Dashboard + Reports + Admin (locked) + +``` + +### With menu header + +```razor + + + My Account + + Profile + Settings + Log out + + +@code { + private void HandleLogout() { /* ... */ } +} +``` + +### Position variants + +```razor +@* Menu opens upward *@ + + Option 1 + Option 2 + + +@* Menu opens to the right *@ + + Option 1 + Option 2 + + +@* Menu opens to the left *@ + + Option 1 + Option 2 + +``` + +### Dark menu theme + +```razor + + New + Edit + Delete + +``` + +### Full-width menu + +```razor + + Option A — longer label + Option B + +``` + +### Large item size + +```razor + + Home + About + Contact + +``` + +### Custom activator template + +Replace the default button with any element while preserving full accessibility and keyboard behaviour: + +```razor + + + + + Profile + Settings + Log out + + +@code { + private void HandleLogout() { /* ... */ } +} +``` + +### Custom activator with additional attributes + +```razor + + + + + New file + Open file + Save + + +@code { + private void HandleNew() { /* ... */ } + private void HandleOpen() { /* ... */ } + private void HandleSave() { /* ... */ } +} +``` + +### With additional HTML attributes on the container + +```razor + + Option 1 + Option 2 + +``` diff --git a/docs/quick-reference.md b/docs/quick-reference.md index d64f2f9..49b10db 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -110,6 +110,50 @@ This guide provides a quick overview of all BitBlazor components with basic exam ``` +### Dropdown +```razor + + + New + Edit + Delete + + + + + New file + Save + Export (unavailable) + + + + + Profile + Settings + + + + + + + + Profile + Log out + +``` + ### Toolbar ```razor diff --git a/src/BitBlazor/Components/Button/BitButton.razor b/src/BitBlazor/Components/Button/BitButton.razor index 7cd6bee..8d4fd93 100644 --- a/src/BitBlazor/Components/Button/BitButton.razor +++ b/src/BitBlazor/Components/Button/BitButton.razor @@ -6,6 +6,7 @@ ; +} diff --git a/src/BitBlazor/Components/Dropdown/BitDropdown.razor.cs b/src/BitBlazor/Components/Dropdown/BitDropdown.razor.cs new file mode 100644 index 0000000..cba8ba3 --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/BitDropdown.razor.cs @@ -0,0 +1,249 @@ +using BitBlazor.Core; +using Microsoft.AspNetCore.Components; + +namespace BitBlazor.Components; + +/// +/// Represents a dropdown component that can be used to display a list of options or actions in a collapsible menu. +/// +public partial class BitDropdown : BitComponentBase +{ + /// + /// Gets or sets the content to be rendered inside the dropdown. This content can include a list of components. + /// + [Parameter] + [EditorRequired] + public RenderFragment ChildContent { get; set; } = default!; + + /// + /// Gets or sets the label for the dropdown activator button. This label is displayed on the button that triggers the dropdown menu. + /// + [Parameter] + public string ActivatorLabel { get; set; } = string.Empty; + + /// + /// Gets or sets the unique identifier for the dropdown activator button. + /// This ID is used to associate the button with the dropdown menu for accessibility purposes. + /// + [Parameter] + [EditorRequired] + public string ActivatorId { get; set; } = string.Empty; + + /// + /// Gets or sets a custom template for the dropdown activator button. + /// + [Parameter] + public RenderFragment? ActivatorTemplate { get; set; } + + /// + /// Gets or sets the position of the dropdown menu relative to its toggle button. + /// + /// + /// The default value is , which positions the menu below the button. + /// Other options include , , and . + /// + [Parameter] + public DropdownPosition Position { get; set; } = DropdownPosition.Down; + + /// + /// Gets or sets the color theme of the dropdown menu. + /// This property allows developers to customize the appearance of the dropdown menu based on their application's design requirements. + /// + [Parameter] + public DropdownMenuColor MenuColor { get; set; } = DropdownMenuColor.Default; + + /// + /// Gets or sets the width of the dropdown menu. + /// + [Parameter] + public DropdownMenuWidth MenuWidth { get; set; } = DropdownMenuWidth.Default; + + /// + /// Gets or sets the size of the dropdown items. + /// This property allows developers to specify the visual size of individual items within the dropdown menu. + /// + [Parameter] + public DropdownItemSize ItemSize { get; set; } = DropdownItemSize.Default; + + /// + /// Gets or sets a custom template for the heading of the dropdown menu. + /// + /// + /// This allows developers to provide a custom UI for the menu's heading section. + /// + [Parameter] + public RenderFragment? MenuHeaderTemplate { get; set; } + + private RenderFragment RenderedActivator => ActivatorTemplate ?? DefaultActivator; + + private bool isOpen; + + internal bool IsOpen => isOpen; + + private readonly List _items = []; + + private IDictionary dropdownMenuAttributes = new Dictionary(); + + private readonly ActivatorContext activatorContext; + + /// + /// Constructs a new instance of the component and initializes the activator context. + /// + public BitDropdown() + { + activatorContext = new(this); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + activatorContext.ActivatorId = ActivatorId; + activatorContext.ActivatorLabel = ActivatorLabel; + } + + internal void Toggle() + { + isOpen = !isOpen; + if (isOpen) + { + dropdownMenuAttributes["data-popper-placement"] = Position switch + { + DropdownPosition.Up => "top-start", + DropdownPosition.End => "right-start", + DropdownPosition.Start => "left-start", + _ => "bottom-start" + }; + + activatorContext.Attributes["aria-expanded"] = "true"; + } + else + { + dropdownMenuAttributes.Remove("data-popper-placement"); + activatorContext.Attributes["aria-expanded"] = "false"; + } + } + + private string ComputeDropdownContainerClass() + { + var builder = new CssClassBuilder("dropdown"); + AddPositionClass(builder); + + AddCustomCssClass(builder); + + return builder.Build(); + } + + private void AddPositionClass(CssClassBuilder builder) + { + var positionClass = Position switch + { + DropdownPosition.Up => "dropup", + DropdownPosition.End => "dropend", + DropdownPosition.Start => "dropstart", + _ => string.Empty + }; + + builder.Add(positionClass); + } + + private string ComputeDropdownMenuClass() + { + var builder = new CssClassBuilder("dropdown-menu"); + + if (isOpen) + { + builder.Add("show"); + } + + AddDropdownMenuColorCssClass(builder); + AddDropdownMenuWidthCssClass(builder); + + return builder.Build(); + } + + private void AddDropdownMenuWidthCssClass(CssClassBuilder builder) + { + var widthClass = MenuWidth switch + { + DropdownMenuWidth.Full => "full-width", + _ => string.Empty + }; + + builder.Add(widthClass); + } + + private void AddDropdownMenuColorCssClass(CssClassBuilder builder) + { + var colorClass = MenuColor switch + { + DropdownMenuColor.Dark => "dark", + _ => string.Empty + }; + + builder.Add(colorClass); + } + + internal void RegisterItem(BitDropdownItem item) => _items.Add(item); + + internal void UnregisterItem(BitDropdownItem item) => _items.Remove(item); + + internal async Task FocusFirstItemAsync() + { + var first = _items.FirstOrDefault(i => !i.Disabled); + if (first is not null) + { + await first.FocusAsync(); + } + } + + internal async Task FocusLastItemAsync() + { + var last = _items.LastOrDefault(i => !i.Disabled); + if (last is not null) + { + await last.FocusAsync(); + } + } + + internal async Task FocusNextItemAsync(BitDropdownItem current) + { + var index = _items.IndexOf(current); + var next = _items + .Skip(index + 1) + .FirstOrDefault(i => !i.Disabled) + ?? _items.FirstOrDefault(i => !i.Disabled); + + if (next is not null) + { + await next.FocusAsync(); + } + } + + internal async Task FocusPreviousItemAsync(BitDropdownItem current) + { + var index = _items.IndexOf(current); + var previous = _items + .Take(index) + .LastOrDefault(i => !i.Disabled) + ?? _items.LastOrDefault(i => !i.Disabled); + + if (previous is not null) + { + await previous.FocusAsync(); + } + } + + internal async Task CloseAsync() + { + isOpen = false; + activatorContext.Attributes["aria-expanded"] = "false"; + dropdownMenuAttributes.Remove("data-popper-placement"); + StateHasChanged(); + + if (activatorContext.ActivatorRef.Id is not null) + { + await activatorContext.ActivatorRef.FocusAsync(); + } + } +} diff --git a/src/BitBlazor/Components/Dropdown/BitDropdown.razor.css b/src/BitBlazor/Components/Dropdown/BitDropdown.razor.css new file mode 100644 index 0000000..e499c91 --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/BitDropdown.razor.css @@ -0,0 +1,18 @@ +/* + * Shrink-wrap the container to the activator button's dimensions. + * + * Bootstrap's positional CSS (left:100%, right:100%, bottom:100%) resolves + * against the nearest positioned ancestor -- the .dropdown container div. + * As a block element it stretches to its parent's full width, so the menu + * lands at the container's far edge rather than the button's edge. + * + * display:inline-block collapses the container to the button's own width/height, + * replicating what Popper.js would compute via getBoundingClientRect() -- + * with zero JS interop. + */ +.dropdown, +.dropup, +.dropend, +.dropstart { + display: inline-block; +} \ No newline at end of file diff --git a/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor new file mode 100644 index 0000000..f0ece1b --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor @@ -0,0 +1,7 @@ +@namespace BitBlazor.Components + +
  • + + @ChildContent + +
  • diff --git a/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.cs b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.cs new file mode 100644 index 0000000..7b1d9bf --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.cs @@ -0,0 +1,161 @@ +using BitBlazor.Core; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace BitBlazor.Components; + +/// +/// Represents an item within a component. +/// Each can contain custom content and is rendered as part of the dropdown menu. +/// +public partial class BitDropdownItem : IDisposable +{ + private ElementReference itemAnchorRef; + [CascadingParameter] + BitDropdown Parent { get; set; } = default!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = default!; + + /// + /// Gets or sets the content to be rendered inside the dropdown item. + /// + [Parameter] + public RenderFragment ChildContent { get; set; } = default!; + + /// + /// Gets or sets the URL that the dropdown item should navigate to when clicked. + /// If this property is set, clicking the item will navigate to the specified URL. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// Gets or sets a callback that is invoked when the dropdown item is clicked. + /// + [Parameter] + public EventCallback OnClick { get; set; } + + /// + /// Gets or sets a value indicating whether the dropdown item is currently active. + /// + [Parameter] + public bool Active { get; set; } + + /// + /// Gets or sets a value indicating whether the dropdown item is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets additional attributes that do not match any of the explicitly defined parameters. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary AdditionalAttributes { get; set; } = new Dictionary(); + + /// + protected override void OnInitialized() + { + if (Parent is null) + { + throw new InvalidOperationException("BitDropdownItem component must be used inside a BitDropdown component"); + } + + Parent.RegisterItem(this); + } + + /// + public void Dispose() => Parent?.UnregisterItem(this); + + internal Task FocusAsync() => itemAnchorRef.FocusAsync().AsTask(); + + /// + protected override void OnParametersSet() + { + SetDisabled(); + } + + private void SetDisabled() + { + if (Disabled) + { + AdditionalAttributes["aria-disabled"] = "true"; + AdditionalAttributes["role"] = "button"; + } + else + { + AdditionalAttributes.Remove("aria-disabled"); + AdditionalAttributes.Remove("role"); + } + } + + private string ComputeLinkCssClass() + { + var builder = new CssClassBuilder("dropdown-item", "list-item"); + + if (Active) + { + builder.Add("active"); + } + + if (Disabled) + { + builder.Add("disabled"); + } + + AddSizeCssClass(builder); + + return builder.Build(); + } + + private void AddSizeCssClass(CssClassBuilder builder) + { + var sizeClass = Parent.ItemSize switch + { + DropdownItemSize.Large => "large", + _ => string.Empty + }; + + builder.Add(sizeClass); + } + + private async Task ClickAsync() + { + if (Disabled) + { + return; + } + + if (OnClick.HasDelegate) + { + await OnClick.InvokeAsync(); + } + else if (Href is not null) + { + NavigationManager.NavigateTo(Href); + } + } + + private async Task OnKeyDownAsync(KeyboardEventArgs args) + { + switch (args.Key) + { + case "Enter" or " ": + await ClickAsync(); + break; + + case "ArrowDown": + await Parent.FocusNextItemAsync(this); + break; + + case "ArrowUp": + await Parent.FocusPreviousItemAsync(this); + break; + + case "Escape": + await Parent.CloseAsync(); + break; + } + } +} diff --git a/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.css b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.css new file mode 100644 index 0000000..adb670a --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.css @@ -0,0 +1,3 @@ +a { + cursor: pointer; +} \ No newline at end of file diff --git a/src/BitBlazor/Components/Dropdown/DropdownItemSize.cs b/src/BitBlazor/Components/Dropdown/DropdownItemSize.cs new file mode 100644 index 0000000..4c9b799 --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/DropdownItemSize.cs @@ -0,0 +1,20 @@ +namespace BitBlazor.Components; + +/// +/// Represents the size options for a dropdown item in a dropdown menu. +/// +/// +/// This enumeration allows developers to specify the visual size of individual items within the dropdown. +/// +public enum DropdownItemSize +{ + /// + /// Default size + /// + Default, + + /// + /// Large size + /// + Large +} diff --git a/src/BitBlazor/Components/Dropdown/DropdownMenuColor.cs b/src/BitBlazor/Components/Dropdown/DropdownMenuColor.cs new file mode 100644 index 0000000..d93bb6d --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/DropdownMenuColor.cs @@ -0,0 +1,20 @@ +namespace BitBlazor.Components; + +/// +/// Represents the color theme of the dropdown menu. +/// +/// +/// This enumeration defines the available color options for the dropdown menu, allowing developers to customize its appearance based on their application's design requirements. +/// +public enum DropdownMenuColor +{ + /// + /// Default color theme for the dropdown menu + /// + Default, + + /// + /// Dark color theme for the dropdown menu + /// + Dark +} diff --git a/src/BitBlazor/Components/Dropdown/DropdownMenuWidth.cs b/src/BitBlazor/Components/Dropdown/DropdownMenuWidth.cs new file mode 100644 index 0000000..044e42e --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/DropdownMenuWidth.cs @@ -0,0 +1,17 @@ +namespace BitBlazor.Components; + +/// +/// Represents the width options for a dropdown menu in a dropdown component. +/// +public enum DropdownMenuWidth +{ + /// + /// Default width, which is determined by the content of the dropdown menu. + /// + Default, + + /// + /// Full width, which makes the dropdown menu expand to the full width of its parent container. + /// + Full +} diff --git a/src/BitBlazor/Components/Dropdown/DropdownPosition.cs b/src/BitBlazor/Components/Dropdown/DropdownPosition.cs new file mode 100644 index 0000000..16ae8fd --- /dev/null +++ b/src/BitBlazor/Components/Dropdown/DropdownPosition.cs @@ -0,0 +1,27 @@ +namespace BitBlazor.Components; + +/// +/// Represents the position of the dropdown menu relative to its toggle button. +/// +public enum DropdownPosition +{ + /// + /// The dropdown menu is positioned below the toggle button. Default value. + /// + Down, + + /// + /// The dropdown menu is positioned above the toggle button. + /// + Up, + + /// + /// The dropdown menu is positioned to the right of the toggle button. + /// + End, + + /// + /// The dropdown menu is positioned to the left of the toggle button. + /// + Start +} diff --git a/stories/BitBlazor.Stories/Components/Stories/Components/BitDropdown.stories.razor b/stories/BitBlazor.Stories/Components/Stories/Components/BitDropdown.stories.razor new file mode 100644 index 0000000..2d5f2a5 --- /dev/null +++ b/stories/BitBlazor.Stories/Components/Stories/Components/BitDropdown.stories.razor @@ -0,0 +1,242 @@ +@attribute [Stories("Components/BitDropdown")] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Behaviors.cs b/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Behaviors.cs new file mode 100644 index 0000000..e016952 --- /dev/null +++ b/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Behaviors.cs @@ -0,0 +1,485 @@ +using BitBlazor.Components; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace BitBlazor.Test.Components.Dropdown; + +public class BitDropdownTest +{ + [Fact] + public void BitDropdown_Should_Open_On_Toggle_Click() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.Click(); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + Assert.Equal("true", button.GetAttribute("aria-expanded")); + } + + [Fact] + public void BitDropdown_Should_Close_On_Toggle_Click_Again() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.Click(); + button.Click(); + + var menu = component.Find("div.dropdown-menu"); + Assert.False(menu.ClassList.Contains("show")); + Assert.Equal("false", button.GetAttribute("aria-expanded")); + } + + [Theory] + [InlineData(DropdownPosition.Down, "bottom-start")] + [InlineData(DropdownPosition.Up, "top-start")] + [InlineData(DropdownPosition.End, "right-start")] + [InlineData(DropdownPosition.Start, "left-start")] + public void BitDropdown_Should_Set_Placement_Attribute_Correctly_When_Open(DropdownPosition position, string expectedPlacement) + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .Add(p => p.Position, position) + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.Click(); + + var menu = component.Find("div.dropdown-menu"); + Assert.Equal(expectedPlacement, menu.GetAttribute("data-popper-placement")); + } + + [Fact] + public void BitDropdownItem_Should_Invoke_OnClick_Callback() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Click Me"))); + + var link = component.Find("a.dropdown-item"); + link.Click(); + + Assert.True(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Navigate_When_Href_Set_And_OnClick_Not_Set() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Href, "/test-page") + .AddChildContent("Navigation Link"))); + + var navManager = ctx.Services.GetRequiredService(); + var link = component.Find("a.dropdown-item"); + link.Click(); + + Assert.Equal("http://localhost/test-page", navManager.Uri); + } + + [Fact] + public void BitDropdownItem_Should_Not_Trigger_Click_When_Disabled() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Disabled, true) + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Disabled Item"))); + + var link = component.Find("a.dropdown-item"); + link.Click(); + + Assert.False(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Handle_Enter_Key_Press() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + link.KeyDown("Enter"); + + Assert.True(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Handle_Space_Key_Press() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + link.KeyDown(" "); + + Assert.True(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Ignore_Other_Key_Presses() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + link.KeyDown("Escape"); + + Assert.False(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Not_Trigger_Key_Press_When_Disabled() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Disabled, true) + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + link.KeyDown("Enter"); + + Assert.False(clicked); + } + + [Fact] + public void BitDropdownItem_Should_Prioritize_OnClick_Over_Href() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var clicked = false; + var navManager = ctx.Services.GetRequiredService(); + var initialUri = navManager.Uri; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Href, "/other-page") + .Add(p => p.OnClick, EventCallback.Factory.Create(ctx, () => clicked = true)) + .AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + link.Click(); + + Assert.True(clicked); + // The URI should not change because OnClick callback takes precedence + Assert.Equal(initialUri, navManager.Uri); + } + + [Fact] + public void BitDropdownItem_Should_Apply_Active_Class() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Active, true) + .AddChildContent("Active Item"))); + + var link = component.Find("a.dropdown-item"); + Assert.Contains("active", link.ClassList); + } + + [Fact] + public void BitDropdownItem_Should_Apply_Disabled_Class_And_Aria_Attribute() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Disabled, true) + .AddChildContent("Disabled Item"))); + + var link = component.Find("a.dropdown-item"); + Assert.Contains("disabled", link.ClassList); + Assert.Equal("true", link.GetAttribute("aria-disabled")); + } + + [Fact] + public void BitDropdownItem_Must_Be_Inside_BitDropdown() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var exception = Assert.Throws(() => ctx.Render(parameters => parameters + .AddChildContent("Orphan Item"))); + + Assert.Contains("BitDropdownItem component must be used inside a BitDropdown component", exception.Message); + } + + [Fact] + public void BitDropdown_Should_Open_On_ArrowDown_Key_On_Activator() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.KeyDown("ArrowDown"); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + } + + [Fact] + public void BitDropdown_Should_Open_On_ArrowUp_Key_On_Activator() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.KeyDown("ArrowUp"); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + } + + [Fact] + public void BitDropdown_Should_Not_Toggle_On_Other_Keys_On_Activator() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.KeyDown("Tab"); + + var menu = component.Find("div.dropdown-menu"); + Assert.DoesNotContain("show", menu.ClassList); + } + + [Fact] + public void BitDropdownItem_Should_Close_Dropdown_On_Escape_Key() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var button = component.Find("button"); + button.Click(); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + + var link = component.Find("a.dropdown-item"); + link.KeyDown("Escape"); + + Assert.DoesNotContain("show", menu.ClassList); + } + + [Fact] + public void BitDropdown_Should_Move_Focus_To_Next_Item_On_ArrowDown_Key() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 1")) + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 2"))); + + var button = component.Find("button"); + button.Click(); + + var firstLink = component.FindAll("a.dropdown-item")[0]; + firstLink.KeyDown("ArrowDown"); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + } + + [Fact] + public void BitDropdown_Should_Move_Focus_To_Previous_Item_On_ArrowUp_Key() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 1")) + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 2"))); + + var button = component.Find("button"); + button.Click(); + + var secondLink = component.FindAll("a.dropdown-item")[1]; + secondLink.KeyDown("ArrowUp"); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + } + + [Fact] + public void BitDropdown_Should_Skip_Disabled_Items_On_ArrowDown_Key() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 1")) + .AddChildContent(itemParams => itemParams + .Add(p => p.Disabled, true) + .AddChildContent("Item 2 (disabled)")) + .AddChildContent(itemParams => + itemParams.AddChildContent("Item 3"))); + + var button = component.Find("button"); + button.Click(); + + var firstLink = component.FindAll("a.dropdown-item")[0]; + firstLink.KeyDown("ArrowDown"); + + var menu = component.Find("div.dropdown-menu"); + Assert.Contains("show", menu.ClassList); + } + + [Fact] + public void BitDropdownItem_Should_Have_Tabindex_Zero_When_Enabled() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => + itemParams.AddChildContent("Item"))); + + var link = component.Find("a.dropdown-item"); + Assert.Equal("0", link.GetAttribute("tabindex")); + } + + [Fact] + public void BitDropdownItem_Should_Have_Tabindex_MinusOne_When_Disabled() + { + using var ctx = new BunitContext(); + ctx.SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + + var component = ctx.Render(parameters => parameters + .Add(p => p.ActivatorLabel, "Open") + .Add(p => p.ActivatorId, "dropdownActivator") + .AddChildContent(itemParams => itemParams + .Add(p => p.Disabled, true) + .AddChildContent("Disabled Item"))); + + var link = component.Find("a.dropdown-item"); + Assert.Equal("-1", link.GetAttribute("tabindex")); + } +} diff --git a/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Rendering.razor b/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Rendering.razor new file mode 100644 index 0000000..82d3a8f --- /dev/null +++ b/tests/BitBlazor.Test/Components/Dropdown/BitDropdownTest.Rendering.razor @@ -0,0 +1,358 @@ +@inherits BunitContext + +@code { + public BitDropdownTest_Rendering() + { + SetRendererInfo(new RendererInfo("InteractiveServer", isInteractive: true)); + } + + [Fact] + public void BitDropdown_Should_Render_Default_Markup_Correctly() + { + var component = Render( + @ + Action 1 + Action 2 + Action 3 + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdown_Should_Render_Custom_CssClass_On_Root() + { + var component = Render( + @ + Action 1 + ); + + var root = component.Find("div"); + Assert.Contains("my-custom", root.ClassList); + } + + [Fact] + public void BitDropdown_Should_Update_AriaExpanded_On_Toggle() + { + var component = Render( + @ + Action 1 + ); + + var button = component.Find("button"); + Assert.Equal("false", button.GetAttribute("aria-expanded")); + + button.Click(); + Assert.Equal("true", button.GetAttribute("aria-expanded")); + } + + [Theory] + [InlineData(DropdownPosition.Down, "")] + [InlineData(DropdownPosition.Up, "dropup")] + [InlineData(DropdownPosition.End, "dropend")] + [InlineData(DropdownPosition.Start, "dropstart")] + public void BitDropdown_Should_Render_Correct_Position(DropdownPosition position, string expectedClass) + { + var component = Render( + @ + Action 1 + Action 2 + Action 3 + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdown_Should_Render_Custom_Activator() + { + var component = Render( + @ + + + @context.ActivatorLabel + + + + + Action 1 + Action 2 + Action 3 + + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdownItem_Should_Render_Active_Class() + { + var component = Render( + @ + Active Item + Inactive Item + ); + + var items = component.FindAll("a.dropdown-item"); + Assert.Equal(2, items.Count); + + var activeItem = items[0]; + Assert.Contains("active", activeItem.ClassList); + + var inactiveItem = items[1]; + Assert.DoesNotContain("active", inactiveItem.ClassList); + } + + [Fact] + public void BitDropdownItem_Should_Render_Disabled_Class_And_Attribute() + { + var component = Render( + @ + Disabled Item + Enabled Item + ); + + var items = component.FindAll("a.dropdown-item"); + Assert.Equal(2, items.Count); + + var disabledItem = items[0]; + Assert.Contains("disabled", disabledItem.ClassList); + Assert.Equal("true", disabledItem.GetAttribute("aria-disabled")); + + var enabledItem = items[1]; + Assert.DoesNotContain("disabled", enabledItem.ClassList); + Assert.Null(enabledItem.GetAttribute("aria-disabled")); + } + + [Fact] + public void BitDropdownItem_Should_Render_With_Href() + { + var component = Render( + @ + Link Item + ); + + var link = component.Find("a.dropdown-item"); + Assert.Equal("/test-page", link.GetAttribute("href")); + } + + [Fact] + public void BitDropdownItem_Should_Render_Additional_Attributes() + { + var component = Render( + @ + Item + ); + + var item = component.Find("a.dropdown-item"); + Assert.Equal("custom-item", item.GetAttribute("data-testid")); + Assert.Equal("Test Item", item.GetAttribute("title")); + } + + [Fact] + public void BitDropdown_Should_Render_Dark_Menu_Correctly() + { + var component = Render( + @ + Action 1 + Action 2 + Action 3 + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdown_Should_Render_Menu_Header_Correctly() + { + var component = Render( + @ + Heading + + Action 1 + Action 2 + Action 3 + + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdown_Should_Render_Items_Size_Correctly() + { + var component = Render( + @ + + Action 1 + Action 2 + Action 3 + + ); + + component.MarkupMatches( + @); + } + + [Fact] + public void BitDropdown_Should_Render_Menu_Full_Width_Correctly() + { + var component = Render( + @ + + Action 1 + Action 2 + Action 3 + + ); + + component.MarkupMatches( + @); + } +}