diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs index b690bb2708..c697fe6e88 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneManageCollectionsDialog.cs @@ -17,6 +17,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; +using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; @@ -27,6 +28,9 @@ namespace osu.Game.Tests.Visual.SongSelect { protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + private DialogOverlay dialogOverlay = null!; private RulesetStore rulesets = null!; private BeatmapManager beatmapManager = null!; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs deleted file mode 100644 index c5a0d7eab8..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCollectionDropdown.cs +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osu.Game.Rulesets; -using osu.Game.Tests.Resources; -using osuTK.Input; -using Realms; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene - { - private RulesetStore rulesets = null!; - private BeatmapManager beatmapManager = null!; - private CollectionDropdown dropdown = null!; - - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - - [BackgroundDependencyLoader] - private void load(GameHost host) - { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(Realm); - - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - } - - [SetUp] - public void SetUp() => Schedule(() => - { - writeAndRefresh(r => r.RemoveAll()); - - Child = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = dropdown = new CollectionDropdown - { - Width = 300, - Y = 100, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - }; - }); - - [Test] - public void TestEmptyCollectionFilterContainsAllBeatmaps() - { - assertCollectionDropdownContains(CollectionsStrings.AllBeatmaps); - assertCollectionHeaderDisplays(CollectionsStrings.AllBeatmaps); - } - - [Test] - public void TestCollectionAddedToDropdown() - { - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); - assertCollectionDropdownContains("1"); - assertCollectionDropdownContains("2"); - } - - [Test] - public void TestCollectionsCleared() - { - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3")))); - - AddUntilStep("check count 5", () => dropdown.ChildrenOfType().Single().ChildrenOfType().Count(), () => Is.EqualTo(5)); - - AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll())); - - AddUntilStep("check count 2", () => dropdown.ChildrenOfType().Single().ChildrenOfType().Count(), () => Is.EqualTo(2)); - } - - [Test] - public void TestCollectionRemovedFromDropdown() - { - BeatmapCollection first = null!; - - AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1")))); - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); - AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first))); - - assertCollectionDropdownContains("1", false); - assertCollectionDropdownContains("2"); - } - - [Test] - public void TestCollectionRenamed() - { - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - assertCollectionDropdownContains("1"); - AddStep("select collection", () => dropdown.Current.Value = dropdown.ItemSource.ElementAt(1)); - - addExpandHeaderStep(); - - AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First")); - - assertCollectionDropdownContains("First"); - assertCollectionHeaderDisplays("First"); - } - - [Test] - public void TestAllBeatmapFilterDoesNotHaveAddButton() - { - addExpandHeaderStep(); - AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0))); - AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent); - } - - [Test] - public void TestCollectionFilterHasAddButton() - { - addExpandHeaderStep(); - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - assertCollectionDropdownContains("1"); - AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1))); - AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent); - } - - [Test] - public void TestButtonDisabledAndEnabledWithBeatmapChanges() - { - addExpandHeaderStep(); - - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - assertCollectionDropdownContains("1"); - - AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value); - - AddStep("set dummy beatmap", () => Beatmap.SetDefault()); - AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value); - } - - [Test] - public void TestButtonChangesWhenAddedAndRemovedFromCollection() - { - addExpandHeaderStep(); - - AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - assertCollectionDropdownContains("1"); - - assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - - AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash))); - assertFirstButtonIs(FontAwesome.Solid.MinusSquare); - - AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear())); - assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - } - - [Test] - public void TestButtonAddsAndRemovesBeatmap() - { - addExpandHeaderStep(); - - AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); - assertCollectionDropdownContains("1"); - assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - - addClickAddOrRemoveButtonStep(1); - AddUntilStep("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); - assertFirstButtonIs(FontAwesome.Solid.MinusSquare); - - addClickAddOrRemoveButtonStep(1); - AddUntilStep("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); - assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - } - - [Test] - public void TestManageCollectionsFilterIsNotSelected() - { - bool received = false; - - addExpandHeaderStep(); - - AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); - assertCollectionDropdownContains("1"); - - AddStep("select collection", () => - { - InputManager.MoveMouseTo(getCollectionDropdownItemAt(1)); - InputManager.Click(MouseButton.Left); - }); - - addExpandHeaderStep(); - - AddStep("watch for filter requests", () => - { - received = false; - dropdown.ChildrenOfType().First().RequestFilter = () => received = true; - }); - - AddStep("click manage collections filter", () => - { - int lastItemIndex = dropdown.ChildrenOfType().Single().Items.Count() - 1; - InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex)); - InputManager.Click(MouseButton.Left); - }); - - AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1"); - - AddAssert("filter request not fired", () => !received); - } - - private void writeAndRefresh(Action action) => Realm.Write(r => - { - action(r); - r.Refresh(); - }); - - private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All().First()); - - private void assertCollectionHeaderDisplays(LocalisableString collectionName, bool shouldDisplay = true) - => AddUntilStep($"collection dropdown header displays '{collectionName}'", - () => shouldDisplay == dropdown.ChildrenOfType().Any(h => h.ChildrenOfType().Any(t => t.Text == collectionName))); - - private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon)); - - private void assertCollectionDropdownContains(LocalisableString collectionName, bool shouldContain = true) => - AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", - // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 - () => shouldContain == dropdown.ChildrenOfType().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName)); - - private IconButton getAddOrRemoveButton(int index) - => getCollectionDropdownItemAt(index).ChildrenOfType().Single(); - - private void addExpandHeaderStep() => AddStep("expand header", () => - { - InputManager.MoveMouseTo(dropdown.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - - private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () => - { - InputManager.MoveMouseTo(getAddOrRemoveButton(index)); - InputManager.Click(MouseButton.Left); - }); - - private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index) - { - // todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079 - CollectionFilterMenuItem item = dropdown.ChildrenOfType().Single().ItemSource.ElementAt(index); - return dropdown.ChildrenOfType().Single(i => i.Item.Text.Value == item.CollectionName); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (rulesets.IsNotNull()) - rulesets.Dispose(); - } - } -} diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs deleted file mode 100644 index e9fec32e12..0000000000 --- a/osu.Game/Collections/CollectionDropdown.cs +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osuTK; -using Realms; - -namespace osu.Game.Collections -{ - /// - /// A dropdown to select the collection to be used to filter results. - /// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good. - /// - public partial class CollectionDropdown : OsuDropdown - { - /// - /// Whether to show the "manage collections..." menu item in the dropdown. - /// - protected virtual bool ShowManageCollectionsItem => true; - - public Action? RequestFilter { private get; set; } - - private readonly BindableList filters = new BindableList(); - - [Resolved] - private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IDisposable? realmSubscription; - - private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem(); - - public CollectionDropdown() - { - ItemSource = filters; - - Current.Value = allBeatmapsItem; - AlwaysShowSearchBar = true; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); - - Current.BindValueChanged(selectionChanged); - } - - private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) - { - if (changes == null) - { - filters.Clear(); - filters.Add(allBeatmapsItem); - filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); - if (ShowManageCollectionsItem) - filters.Add(new ManageCollectionsFilterMenuItem()); - } - else - { - foreach (int i in changes.DeletedIndices.OrderDescending()) - filters.RemoveAt(i + 1); - - foreach (int i in changes.InsertedIndices) - filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm))); - - var selectedItem = SelectedItem?.Value; - - foreach (int i in changes.NewModifiedIndices) - { - var updatedItem = collections[i]; - - // This is responsible for updating the state of the +/- button and the collection's name. - // TODO: we can probably make the menu items update with changes to avoid this. - filters.RemoveAt(i + 1); - filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm))); - - if (updatedItem.ID == selectedItem?.Collection?.ID) - { - // This current update and schedule is required to work around dropdown headers not updating text even when the selected item - // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue - // a warning that it's going to be a frustrating journey. - Current.Value = allBeatmapsItem; - Schedule(() => - { - // current may have changed before the scheduled call is run. - if (Current.Value != allBeatmapsItem) - return; - - Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0]; - }); - - // Trigger an external re-filter if the current item was in the change set. - RequestFilter?.Invoke(); - break; - } - } - } - } - - private Live? lastFiltered; - - private void selectionChanged(ValueChangedEvent filter) - { - // May be null during .Clear(). - if (filter.NewValue.IsNull()) - return; - - // Never select the manage collection filter - rollback to the previous filter. - // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. - if (filter.NewValue is ManageCollectionsFilterMenuItem) - { - Current.Value = filter.OldValue; - manageCollectionsDialog?.Show(); - return; - } - - var newCollection = filter.NewValue.Collection; - - // This dropdown be weird. - // We only care about filtering if the actual collection has changed. - if (newCollection != lastFiltered) - { - RequestFilter?.Invoke(); - lastFiltered = newCollection; - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - realmSubscription?.Dispose(); - } - - protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; - - protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader(); - - protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); - - protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); - - protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); - - public partial class CollectionDropdownHeader : OsuDropdownHeader - { - public CollectionDropdownHeader() - { - Height = 25; - Chevron.Size = new Vector2(12); - Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 8 }; - } - } - - protected partial class CollectionDropdownMenu : OsuDropdownMenu - { - public CollectionDropdownMenu() - { - MaxHeight = 200; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item) - { - BackgroundColourHover = HoverColour, - BackgroundColourSelected = SelectionColour - }; - } - - protected partial class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem - { - private IconButton addOrRemoveButton = null!; - - private bool beatmapInCollection; - - private readonly Live? collection; - - [Resolved] - private IBindable beatmap { get; set; } = null!; - - public CollectionDropdownDrawableMenuItem(MenuItem item) - : base(item) - { - collection = ((DropdownMenuItem)item).Value.Collection; - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(addOrRemoveButton = new NoFocusChangeIconButton - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - X = -OsuScrollContainer.SCROLL_BAR_WIDTH, - Scale = new Vector2(0.65f), - Action = addOrRemove, - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (collection != null) - { - beatmap.BindValueChanged(_ => - { - beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); - - addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; - addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; - addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; - - updateButtonVisibility(); - }, true); - } - - updateButtonVisibility(); - } - - protected override bool OnHover(HoverEvent e) - { - updateButtonVisibility(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateButtonVisibility(); - base.OnHoverLost(e); - } - - protected override void OnSelectChange() - { - base.OnSelectChange(); - updateButtonVisibility(); - } - - private void updateButtonVisibility() - { - if (collection == null) - addOrRemoveButton.Alpha = 0; - else - addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; - } - - private void addOrRemove() - { - Debug.Assert(collection != null); - - Task.Run(() => collection.PerformWrite(c => - { - if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) - c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); - })); - } - - protected override Drawable CreateContent() => (Content)base.CreateContent(); - - private partial class NoFocusChangeIconButton : IconButton - { - public override bool ChangeFocusOnClick => false; - } - } - } -} diff --git a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs deleted file mode 100644 index 2ba222b976..0000000000 --- a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osuTK.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; -using osu.Game.Collections; -using osu.Game.Graphics; - -namespace osu.Game.Overlays.Music -{ - /// - /// A for use in the . - /// - public partial class NowPlayingCollectionDropdown : CollectionDropdown // TODO: class is now unused. if we decide this isn't coming back it can be nuked. - { - protected override bool ShowManageCollectionsItem => false; - - protected override CollectionDropdownHeader CreateCollectionHeader() => new CollectionsHeader(); - - protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu(); - - private partial class CollectionsMenu : CollectionDropdownMenu - { - public CollectionsMenu() - { - Masking = true; - CornerRadius = 5; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Gray4; - SelectionColour = colours.Gray5; - HoverColour = colours.Gray6; - } - } - - private partial class CollectionsHeader : CollectionDropdownHeader - { - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Gray4; - BackgroundColourHover = colours.Gray6; - } - - public CollectionsHeader() - { - CornerRadius = 5; - Height = 30; - Chevron.Size = new Vector2(14); - Chevron.Margin = new MarginPadding(0); - Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 10, Right = 10 }; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.3f), - Radius = 3, - Offset = new Vector2(0f, 1f), - }; - } - } - } -} diff --git a/osu.Game/Screens/Select/CollectionDropdown.cs b/osu.Game/Screens/Select/CollectionDropdown.cs index ee22637541..5865373a9c 100644 --- a/osu.Game/Screens/Select/CollectionDropdown.cs +++ b/osu.Game/Screens/Select/CollectionDropdown.cs @@ -27,7 +27,6 @@ namespace osu.Game.Screens.Select { /// /// A dropdown to select the collection to be used to filter results. - /// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good. /// public partial class CollectionDropdown : ShearedDropdown // TODO: partial class under FilterControl? {