diff --git a/Tesserae.Tests/src/App.cs b/Tesserae.Tests/src/App.cs index d1bb4f6b..14ceb2d5 100644 --- a/Tesserae.Tests/src/App.cs +++ b/Tesserae.Tests/src/App.cs @@ -52,7 +52,9 @@ private static void Main() ("Defer", () => new DeferSample()), ("Toast", () => new ToastSample()), ("LineAwesomeIcons", () => new LineAwesomeSample()), - ("FileSelector", () => new FileSelectorAndDropAreaSample()) + ("FileSelector", () => new FileSelectorAndDropAreaSample()), + ("Observable", () => new ObservableSample()), + }; var sideBar = Sidebar().Stretch(); @@ -175,6 +177,7 @@ private static IComponent MainNav(Dictionary links, Navbar links["Toast"], links["FileSelector"], links["LineAwesomeIcons"], + links["Observable"], links["ProgressModal"]), NavLink("Collections").Expanded() .SmallPlus() diff --git a/Tesserae.Tests/src/Samples/CheckBoxSample.cs b/Tesserae.Tests/src/Samples/CheckBoxSample.cs index 2d63bf47..df9c05b1 100644 --- a/Tesserae.Tests/src/Samples/CheckBoxSample.cs +++ b/Tesserae.Tests/src/Samples/CheckBoxSample.cs @@ -13,16 +13,16 @@ public class CheckBoxSample : IComponent public CheckBoxSample() { _content = SectionStack() - .Title(SampleHeader(nameof(CheckBoxSample))) - .Section(Stack().Children( + .Title(SampleHeader(nameof(CheckBoxSample))) + .Section(Stack().Children( SampleTitle("Overview"), TextBlock("A CheckBox is a UI element that allows users to switch between two mutually exclusive options (checked or unchecked, on or off) through a single click or tap. It can also be used to indicate a subordinate setting or preference when paired with another control."), TextBlock("A CheckBox is used to select or deselect action items. It can be used for a single item or for a list of multiple items that a user can choose from. The control has two selection states: unselected and selected."), TextBlock("Use a single CheckBox for a subordinate setting, such as with a \"Remember me ? \" login scenario or with a terms of service agreement."), TextBlock("For a binary choice, the main difference between a CheckBox and a toggle switch is that the CheckBox is for status and the toggle switch is for action. You can delay committing a CheckBox interaction (as part of a form submit, for example), while you should immediately commit a toggle switch interaction. Also, only CheckBoxes allow for multi-selection."), TextBlock("Use multiple CheckBoxes for multi-select scenarios in which a user chooses one or more items from a group of choices that are not mutually exclusive.") - )) - .Section(Stack().Children( + )) + .Section(Stack().Children( SampleTitle("Best Practices"), Stack().Horizontal().Children( Stack().Width(40.percent()).Children( @@ -35,14 +35,14 @@ public CheckBoxSample() SampleDont("Don’t use a CheckBox when the user can choose only one option from the group, use radio buttons instead."), SampleDont("Don't put two groups of CheckBoxes next to each other. Separate the two groups with labels.") )))) - .Section(Stack().Children( + .Section(Stack().Children( SampleTitle("Usage"), TextBlock("Basic CheckBoxes").Medium(), CheckBox("Unchecked checkbox"), CheckBox("Checked checkbox").Checked(), CheckBox("Disabled checkbox").Disabled(), CheckBox("Disabled checked checkbox").Checked().Disabled() - )); + )); } public HTMLElement Render() @@ -50,4 +50,4 @@ public HTMLElement Render() return _content.Render(); } } -} +} \ No newline at end of file diff --git a/Tesserae.Tests/src/Samples/ObservableSample.cs b/Tesserae.Tests/src/Samples/ObservableSample.cs new file mode 100644 index 00000000..271710f3 --- /dev/null +++ b/Tesserae.Tests/src/Samples/ObservableSample.cs @@ -0,0 +1,123 @@ +using System.Linq; +using H5.Core; +using Tesserae.Components; +using static Tesserae.UI; +using static Tesserae.Tests.Samples.SamplesHelper; + +namespace Tesserae.Tests.Samples +{ + public class ObservableSample : IComponent + { + private readonly IComponent _content; + + public ObservableSample() + { + var boolObservable = new SettableObservable(true); + var stringObservable = new SettableObservable("Text that has been set in the Observable"); + var colorObservable = new SettableObservable(""); + var dateObservable = new SettableObservable(""); + var dateTimeObservable = new SettableObservable(""); + var monthObservable = new SettableObservable(""); + var weekObservable = new SettableObservable(""); + var timeObservable = new SettableObservable(""); + var sliderObservable = new SettableObservable(0); + + _content = SectionStack() + .Title(SampleHeader(nameof(ObservableSample))) + .Section(Stack().Children( + SampleTitle("Overview"), + TextBlock("Components that implement the \"Bindable\" interface can be bound to a \"Observable\" value."), + TextBlock("The internal state of the Component are then kept in sync with the Observable."))) + .Section(Stack().Children( + SampleTitle("Best Practices"), + Stack().Horizontal().Children( + Stack().Width(40.percent()).Children( + SampleSubTitle("Do"), + SampleDo("TODO")), + Stack().Width(40.percent()).Children( + SampleSubTitle("Don't"), + SampleDont("TODO"))))) + .Section(Stack().Children( + SampleTitle("Usage"), + SectionStack() + .Section(Stack().Children( + SampleTitle("Bindable to bool"), + Label("Value: ").Inline().SetContent(Defer(boolObservable, value => TextBlock(value.ToString()).AsTask())), + CheckBox("CheckBox global").Bind(boolObservable), + Toggle("Toggle global").Bind(boolObservable))) + .Section(Stack().Children( + SampleTitle("Bindable to string"), + Label("Value: ").Inline().SetContent(Defer(stringObservable, value => TextBlock(value.ToString()).AsTask())), + TextBox("Initial value TextBox").Bind(stringObservable), + TextArea("Initial value TextArea").Bind(stringObservable), + EditableLabel("Initial value EditableLabel").Bind(stringObservable), + EditableArea("Initial value EditableArea").Bind(stringObservable))) + .Section(Stack().Children( + SampleTitle("Slider"), + Slider(100, 0, 100, 1).Bind(sliderObservable), + Slider(100, 0, 100, 1).Bind(sliderObservable), + Label("Slider: ").Inline().SetContent(Defer(sliderObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("ColorPicker"), + ColorPicker().Bind(colorObservable).Width(200.px()), + ColorPicker().Bind(colorObservable).Width(200.px()), + Label("Color: ").Inline().SetContent(Defer(colorObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("DatePicker"), + new DatePicker().Bind(dateObservable).Width(200.px()), + new DatePicker().Bind(dateObservable).Width(200.px()), + Label("Date: ").Inline().SetContent(Defer(dateObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("DateTimePicker"), + DateTimePicker().Bind(dateTimeObservable).Width(200.px()), + DateTimePicker().Bind(dateTimeObservable).Width(200.px()), + Label("DateTime: ").Inline().SetContent(Defer(monthObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("MonthPicker"), + new MonthPicker((1, 1)).Bind(monthObservable).Width(200.px()), + new MonthPicker((1, 1)).Bind(monthObservable).Width(200.px()), + Label("Month: ").Inline().SetContent(Defer(colorObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("TimePicker"), + new TimePicker().Bind(timeObservable).Width(200.px()), + new TimePicker().Bind(timeObservable).Width(200.px()), + Label("Time: ").Inline().SetContent(Defer(timeObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("WeekPicker"), + new WeekPicker((1, 1)).Bind(weekObservable).Width(200.px()), + new WeekPicker((1, 1)).Bind(weekObservable).Width(200.px()), + Label("Week: ").Inline().SetContent(Defer(weekObservable, value => TextBlock(value.ToString()).AsTask())))) + .Section(Stack().Children( + SampleTitle("ChoiceGroup"), + ChoiceGroup("Observable ChoiceGroup").Required() + .Choices( + Choice("Option A"), + Choice("Option B"), + Choice("Option C"), + Choice("Option D")).Var(out var choiceGroup), + Label("Choice: ").Inline().SetContent(Defer(choiceGroup.AsObservable(), value => TextBlock(value?.Text ?? "").AsTask())))) + .Section(Stack().Children( + SampleTitle("Dropdown"), + Dropdown("Observable Multi Dropdown").Required().Width(200.px()).Multi() + .Items( + DropdownItem("Option A"), + DropdownItem("Option B"), + DropdownItem("Option C"), + DropdownItem("Option D")).Var(out var dropdownMulti), + Label("Multi Dropdown Selected: ").Inline().SetContent(Defer(dropdownMulti.AsObservableList(), value => TextBlock(string.Join(", ", value.Select(e => e.Text))).AsTask())), + Dropdown("Observable Dropdown").Required().Width(200.px()) + .Items( + DropdownItem("Option A"), + DropdownItem("Option B"), + DropdownItem("Option C"), + DropdownItem("Option D")).Var(out var dropdown), + Label("Dropdown Selected: ").Inline().SetContent(Defer(dropdown.AsObservableList(), value => TextBlock(string.Join(", ", value.Select(e => e.Text))).AsTask())))) + )); + } + + public dom.HTMLElement Render() + { + return _content.Render(); + } + } +} \ No newline at end of file diff --git a/Tesserae/src/Components/CheckBox.cs b/Tesserae/src/Components/CheckBox.cs index 4d404fd4..ad343a7b 100644 --- a/Tesserae/src/Components/CheckBox.cs +++ b/Tesserae/src/Components/CheckBox.cs @@ -1,19 +1,57 @@ -using static H5.Core.dom; +using System; +using static H5.Core.dom; using static Tesserae.UI; namespace Tesserae.Components { - public class CheckBox : ComponentBase, IObservableComponent + public class CheckBox : ComponentBase, IBindableComponent { - private readonly HTMLSpanElement _checkSpan; + private readonly HTMLSpanElement _checkSpan; private readonly HTMLLabelElement _label; - private readonly SettableObservable _observable = new SettableObservable(); + + private SettableObservable _observable; + private ObservableEvent.ValueChanged valueGetter; + private bool _observableReferenceUsed = false; + + public SettableObservable Observable + { + get + { + _observableReferenceUsed = true; + return _observable; + } + set + { + if (_observableReferenceUsed) + { + throw new ArgumentException("Can't set the observable after a reference of it has been used! (.AsObservable() might have been called before .Bind())"); + } + + if (_observable is object) + _observable.StopObserving(valueGetter); + _observable = value; + _observable.Observe(valueGetter); + } + } public CheckBox(string text = string.Empty) { + + InnerElement = CheckBox(_("tss-checkbox")); - _checkSpan = Span(_("tss-checkbox-mark")); - _label = Label(_("tss-checkbox-container", text: text), InnerElement, _checkSpan); + _checkSpan = Span(_("tss-checkbox-mark")); + _label = Label(_("tss-checkbox-container", text: text), InnerElement, _checkSpan); + + valueGetter = v => IsChecked = v; + Observable = new SettableObservable(); + + InnerElement.onchange += (e) => + { + StopEvent(e); + IsChecked = IsChecked; + RaiseOnChange(ev: null); + }; + AttachClick(); AttachChange(); AttachFocus(); @@ -57,7 +95,7 @@ public bool IsChecked set { InnerElement.@checked = value; - _observable.Value = value; + _observable.Value = value; } } @@ -83,10 +121,5 @@ public CheckBox SetText(string text) Text = text; return this; } - - public IObservable AsObservable() - { - return _observable; - } } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/ChoiceGroup.cs b/Tesserae/src/Components/ChoiceGroup.cs index eb7ccf39..4407e059 100644 --- a/Tesserae/src/Components/ChoiceGroup.cs +++ b/Tesserae/src/Components/ChoiceGroup.cs @@ -7,16 +7,26 @@ namespace Tesserae.Components public sealed class ChoiceGroup : ComponentBase, IContainer, IObservableComponent { private readonly TextBlock _header; - private readonly SettableObservable _selectedOption = new SettableObservable(); + + private SettableObservable _selectedOption; + + public IObservable Observable => _selectedOption; + public ChoiceGroup(string label = "Pick one") { _header = (new TextBlock(label)).SemiBold(); var h = _header.Render(); h.style.alignSelf = "baseline"; - InnerElement = Div(_("tss-choice-group", styles: s => { s.flexDirection = "column"; }), h); + InnerElement = Div(_("tss-choice-group", styles: s => { s.flexDirection = "column"; }), h); + + _selectedOption = new SettableObservable(); } - public Choice SelectedOption { get => _selectedOption.Value; private set => _selectedOption.Value = value; } + public Choice SelectedOption + { + get => _selectedOption.Value; + private set => _selectedOption.Value = value; + } public string Label { @@ -30,7 +40,7 @@ public ChoiceGroupOrientation Orientation set { if (value == ChoiceGroupOrientation.Horizontal) InnerElement.style.flexDirection = "row"; - else InnerElement.style.flexDirection = "column"; + else InnerElement.style.flexDirection = "column"; } } @@ -79,6 +89,7 @@ public ChoiceGroup Horizontal() Orientation = ChoiceGroup.ChoiceGroupOrientation.Horizontal; return this; } + public ChoiceGroup Vertical() { Orientation = ChoiceGroup.ChoiceGroupOrientation.Vertical; @@ -95,7 +106,7 @@ private void OnChoiceSelected(Choice sender) { if (SelectedOption == sender) return; - + if (SelectedOption is object) SelectedOption.IsSelected = false; @@ -104,8 +115,6 @@ private void OnChoiceSelected(Choice sender) RaiseOnChange(ev: null); } - public IObservable AsObservable() => _selectedOption; - public enum ChoiceGroupOrientation { Vertical, @@ -116,13 +125,14 @@ public sealed class Choice : ComponentBase { private event ComponentEventHandler SelectedItem; - private readonly HTMLSpanElement _radioSpan; + private readonly HTMLSpanElement _radioSpan; private readonly HTMLLabelElement _label; + public Choice(string text) { InnerElement = RadioButton(_("tss-option")); - _radioSpan = Span(_("tss-option-mark")); - _label = Label(_("tss-option-container", text: text), InnerElement, _radioSpan); + _radioSpan = Span(_("tss-option-mark")); + _label = Label(_("tss-option-container", text: text), InnerElement, _radioSpan); AttachClick(); AttachChange(); AttachFocus(); @@ -193,6 +203,7 @@ public Choice SelectedIf(bool shouldSelect) { IsSelected = true; } + return this; } diff --git a/Tesserae/src/Components/ColorPicker.cs b/Tesserae/src/Components/ColorPicker.cs index b8f35c1b..9290e0a1 100644 --- a/Tesserae/src/Components/ColorPicker.cs +++ b/Tesserae/src/Components/ColorPicker.cs @@ -1,13 +1,11 @@ namespace Tesserae.Components { - public class ColorPicker : Input + public class ColorPicker : Input, IBindableComponent { - public ColorPicker(Color color) : base("color", color?.ToHex() ?? "#000000") - { - } + public ColorPicker(Color color) : base("color", color?.ToHex() ?? "#000000") { } public Color Color => Color.FromString(Text); public ColorPicker SetColor(Color color) => SetText(color.ToHex()); } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/DatePicker.cs b/Tesserae/src/Components/DatePicker.cs index e7bf2662..487a7809 100644 --- a/Tesserae/src/Components/DatePicker.cs +++ b/Tesserae/src/Components/DatePicker.cs @@ -3,12 +3,10 @@ namespace Tesserae.Components { - public class DatePicker : MomentPickerBase + public class DatePicker : MomentPickerBase, IBindableComponent { public DatePicker(DateTime? date = null) - : base("date", date.HasValue ? FormatDateTime(date.Value) : string.Empty) - { - } + : base("date", date.HasValue ? FormatDateTime(date.Value) : string.Empty) { } public DateTime Date => Moment; @@ -38,4 +36,4 @@ protected override DateTime FormatMoment(string date) return default; } } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/DateTimePicker.cs b/Tesserae/src/Components/DateTimePicker.cs index b3fc2eec..cac7ab1d 100644 --- a/Tesserae/src/Components/DateTimePicker.cs +++ b/Tesserae/src/Components/DateTimePicker.cs @@ -3,12 +3,10 @@ namespace Tesserae.Components { - public class DateTimePicker : MomentPickerBase + public class DateTimePicker : MomentPickerBase, IBindableComponent { public DateTimePicker(DateTime? dateTime = null) - : base("datetime-local", dateTime.HasValue ? FormatDateTime(dateTime.Value) : string.Empty) - { - } + : base("datetime-local", dateTime.HasValue ? FormatDateTime(dateTime.Value) : string.Empty) { } public DateTime DateTime => Moment; @@ -38,4 +36,4 @@ protected override DateTime FormatMoment(string dateTime) return default; } } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/Dropdown.cs b/Tesserae/src/Components/Dropdown.cs index c683add4..ff463922 100644 --- a/Tesserae/src/Components/Dropdown.cs +++ b/Tesserae/src/Components/Dropdown.cs @@ -15,21 +15,34 @@ public sealed class Dropdown : Layer, ICanValidate, IObserva private static HTMLElement _firstItem; - private readonly HTMLElement _childContainer; - private readonly HTMLDivElement _container; + private readonly HTMLElement _childContainer; + private readonly HTMLDivElement _container; private readonly HTMLSpanElement _noItemsSpan; private readonly HTMLSpanElement _errorSpan; - private readonly ObservableList _selectedChildren; private IComponent _placeholder; - private HTMLDivElement _spinner; - private bool _isChanged; - private bool _callSelectOnChangingItemSelections; - private Func> _itemsSource; + + private ObservableList _observable; + private ObservableEvent.ValueChanged> valueGetter; + private bool _observableReferenceUsed = false; + + public IObservable> ObservableList + { + get + { + _observableReferenceUsed = true; + return _observable; + } + } + + private HTMLDivElement _spinner; + private bool _isChanged; + private bool _callSelectOnChangingItemSelections; + private Func> _itemsSource; private ReadOnlyArray _lastRenderedItems; - private HTMLDivElement _popupDiv; - private string _search; - private int _latestRequestID; + private HTMLDivElement _popupDiv; + private string _search; + private int _latestRequestID; public Dropdown(HTMLSpanElement noItemsSpan = null) { @@ -58,7 +71,9 @@ public Dropdown(HTMLSpanElement noItemsSpan = null) }; _callSelectOnChangingItemSelections = true; - _selectedChildren = new ObservableList(); + + valueGetter = items => RenderSelected(); + _observable = new ObservableList(new Item[] { }); _latestRequestID = 0; } @@ -85,15 +100,9 @@ public SelectMode Mode } } - public Item[] SelectedItems => _selectedChildren.ToArray(); + public Item[] SelectedItems => _observable.ToArray(); - public string SelectedText - { - get - { - return string.Join(", ", _selectedChildren.Select(x => x.Text)); - } - } + public string SelectedText => string.Join(", ", _observable.Select(x => x.Text)); public string Error { @@ -167,13 +176,7 @@ public bool IsRequired public override HTMLElement Render() { - DomObserver.WhenMounted(_container, () => - { - DomObserver.WhenRemoved(_container, () => - { - Hide(); - }); - }); + DomObserver.WhenMounted(_container, () => { DomObserver.WhenRemoved(_container, () => { Hide(); }); }); return _container; } @@ -214,13 +217,13 @@ public override void Show() { if (_contentHtml == null) { - _popupDiv = Div(_("tss-dropdown-popup"), _childContainer); + _popupDiv = Div(_("tss-dropdown-popup"), _childContainer); _contentHtml = Div(_("tss-dropdown-layer"), _popupDiv); - _contentHtml.addEventListener("click", OnWindowClick); - _contentHtml.addEventListener("dblclick", OnWindowClick); + _contentHtml.addEventListener("click", OnWindowClick); + _contentHtml.addEventListener("dblclick", OnWindowClick); _contentHtml.addEventListener("contextmenu", OnWindowClick); - _contentHtml.addEventListener("wheel", OnWindowClick); + _contentHtml.addEventListener("wheel", OnWindowClick); if (_itemsSource is object) { @@ -230,8 +233,8 @@ public override void Show() } _popupDiv.style.height = "unset"; - _popupDiv.style.left = "-1000px"; - _popupDiv.style.top = "-1000px"; + _popupDiv.style.left = "-1000px"; + _popupDiv.style.top = "-1000px"; base.Show(); @@ -244,19 +247,19 @@ public override void Show() DomObserver.WhenMounted(_popupDiv, () => { document.addEventListener("keydown", OnPopupKeyDown); - if (_selectedChildren.Count > 0) + if (_observable.Count > 0) { - _selectedChildren[_selectedChildren.Count - 1].Render().focus(); + _observable[_observable.Count - 1].Render().focus(); } }); } private void RecomputePopupPosition() { - ClientRect rect = (ClientRect)_container.getBoundingClientRect(); - var contentRect = (ClientRect)_popupDiv.getBoundingClientRect(); - _popupDiv.style.top = rect.bottom - 1 + "px"; - _popupDiv.style.minWidth = rect.width + "px"; + ClientRect rect = (ClientRect) _container.getBoundingClientRect(); + var contentRect = (ClientRect) _popupDiv.getBoundingClientRect(); + _popupDiv.style.top = rect.bottom - 1 + "px"; + _popupDiv.style.minWidth = rect.width + "px"; var finalLeft = rect.left; if (rect.left + contentRect.width + 1 > window.innerWidth) @@ -273,7 +276,7 @@ private void RecomputePopupPosition() { if (rect.top > window.innerHeight - rect.bottom - 1) { - _popupDiv.style.top = "1px"; + _popupDiv.style.top = "1px"; _popupDiv.style.height = rect.top - 1 + "px"; } else @@ -292,11 +295,11 @@ public override void Hide(Action onHidden = null) { ClearSearch(); ResetSearchItems(); - document.removeEventListener("click", OnWindowClick); - document.removeEventListener("dblclick", OnWindowClick); + document.removeEventListener("click", OnWindowClick); + document.removeEventListener("dblclick", OnWindowClick); document.removeEventListener("contextmenu", OnWindowClick); - document.removeEventListener("wheel", OnWindowClick); - document.removeEventListener("keydown", OnPopupKeyDown); + document.removeEventListener("wheel", OnWindowClick); + document.removeEventListener("keydown", OnPopupKeyDown); base.Hide(onHidden); if (_isChanged) RaiseOnChange(ev: null); @@ -342,7 +345,7 @@ public Dropdown Items(params Item[] children) // 2020-06-11 DWR: We need to do this, otherwise the entries in there will relate to drop down items that are no longer rendered - it's fine, since we'll be rebuilding the items (including selected states) if we've just called clear // TODO [2020-07-01 DWR]: It doesn't LOOK to me like this is required any more since we will always call it in UpdateStateBasedUponCurrentSelections a little further below.. but I want to test with it removed before I'm fully confident - _selectedChildren.Clear(); + _observable.Clear(); // Each request (whether sync or async) will get a unique and incrementing ID - if requests overlap then the results of requests that were initiated later are preferred as they are going to be the results of interactions that User // performed since the earlier requests started (since this code is browser-based, and so single-threaded, it's only possible for async requests to overlap - synchronous requests never can - but it's important to increment the @@ -373,6 +376,7 @@ public Dropdown Items(params Item[] children) Disabled(true); _noItemsSpan.style.display = ""; } + return this; } @@ -385,7 +389,7 @@ public Dropdown Items(Func> itemsSource) // 2020-06-30 DWR: We should only show the no-items message if we KNOW that there are no options to select and if we've just specified an async retrieval then we won't know whether there are any items or not until it completes - so we'll // ensure that the message is hidden here and then it will be displayed/hidden as appropriate when the async retrieval completes (the non-async Items method will be called and that updates the display state of the no-items message) _noItemsSpan.style.display = "none"; - _itemsSource = itemsSource; + _itemsSource = itemsSource; return this; } @@ -435,8 +439,8 @@ private void UpdateStateBasedUponCurrentSelections() // selected - based on the items that were selected here before the update. This is because THIS component only deals with Dropdown.Item instances and NOT the source values and it can't be 100% sure that if an item with text "ABC" // was selected before and then new items arrive that any item with text "ABC" should still be selected - only the caller knows strongly-typed values that these dropdown items relate to. // ^ This is less of an issue with single-select configurations since they can only show zero or one selections and so ordering is not important - _selectedChildren.Clear(); - _selectedChildren.AddRange(_lastRenderedItems.Where(item => item.IsSelected)); + _observable.Clear(); + _observable.AddRange(_lastRenderedItems.Where(item => item.IsSelected)); RenderSelected(); } @@ -458,6 +462,7 @@ private void OnItemSelected(Item sender) // If this is an item getting unselected in a Single-only dropdown then it was probably from the "selectedChild.IsSelected = false" call just above and we can ignore it since we're already processing the OnItemSelected logic return; } + Hide(); } else @@ -497,6 +502,15 @@ private void RenderSelected() rendered.classList.add("tss-dropdown-item-on-box"); InnerElement.appendChild(rendered); } + else + { + var sel = SelectedItems[i]; + var clone = sel.RenderSelected(); + clone.classList.remove("tss-dropdown-item"); + clone.classList.remove("tss-selected"); + clone.classList.add("tss-dropdown-item-on-box"); + InnerElement.appendChild(clone); + } } // 2020-06-30 DWR: This may or may not be visible right now, it doesn't matter - we just need to ensure that we add it back in after clearing InnerElement @@ -510,7 +524,7 @@ private void OnPopupKeyDown(Event e) if (ev.key == "ArrowUp") { - var visibleItems = _childContainer.children.Where(he => ((HTMLElement)he).style.display != "none").ToArray(); + var visibleItems = _childContainer.children.Where(he => ((HTMLElement) he).style.display != "none").ToArray(); if (_popupDiv.classList.contains("tss-no-focus")) _popupDiv.classList.remove("tss-no-focus"); @@ -537,7 +551,7 @@ private void OnPopupKeyDown(Event e) } else if (ev.key == "ArrowDown") { - var visibleItems = _childContainer.children.Where(he => ((HTMLElement)he).style.display != "none").ToArray(); + var visibleItems = _childContainer.children.Where(he => ((HTMLElement) he).style.display != "none").ToArray(); if (_popupDiv.classList.contains("tss-no-focus")) _popupDiv.classList.remove("tss-no-focus"); @@ -568,11 +582,6 @@ private void OnPopupKeyDown(Event e) } } - public IObservable> AsObservable() - { - return _selectedChildren; - } - public enum SelectMode { Single, @@ -660,13 +669,13 @@ private void EnsureAsyncLoadingStateDisabled() _container.style.pointerEvents = "unset"; } - private (HTMLElement item, string textContent)[] GetItems() => _childContainer.children.Select(child => ((HTMLElement)child, child.textContent)).ToArray(); + private (HTMLElement item, string textContent)[] GetItems() => _childContainer.children.Select(child => ((HTMLElement) child, child.textContent)).ToArray(); private void SearchItems() { - var items = GetItems(); + var items = GetItems(); var itemsToRemove = items.Where(item => !(item.textContent.ToLower().Contains(_search.ToLower()))); - var itemsToReset = items.Except(itemsToRemove); + var itemsToReset = items.Except(itemsToRemove); _firstItem = itemsToReset.FirstOrDefault().item; ResetSearchItems(itemsToReset); @@ -675,6 +684,7 @@ private void SearchItems() { item.style.display = "none"; } + RecomputePopupPosition(); } @@ -693,7 +703,7 @@ public Item(IComponent content, IComponent selectedContent) if (selectedContent is null || selectedContent == content) { - SelectedElement = (HTMLElement)InnerElement.cloneNode(true); + SelectedElement = (HTMLElement) InnerElement.cloneNode(true); } else { @@ -701,12 +711,12 @@ public Item(IComponent content, IComponent selectedContent) SelectedElement.appendChild(selectedContent.Render()); } - InnerElement.addEventListener("click", OnItemClick); + InnerElement.addEventListener("click", OnItemClick); InnerElement.addEventListener("mouseover", OnItemMouseOver); } - private event BeforeSelectEventHandler BeforeSelectedItem; - internal event ComponentEventHandler SelectedItem; + private event BeforeSelectEventHandler BeforeSelectedItem; + internal event ComponentEventHandler SelectedItem; public ItemType Type { @@ -722,7 +732,7 @@ public ItemType Type InnerElement.classList.add($"tss-dropdown-{value.ToString().ToLower()}"); if (value == ItemType.Item) InnerElement.tabIndex = 0; - else InnerElement.tabIndex = -1; + else InnerElement.tabIndex = -1; } } @@ -812,6 +822,7 @@ public Item SelectedIf(bool shouldSelect) { IsSelected = true; } + return this; } diff --git a/Tesserae/src/Components/EditableArea.cs b/Tesserae/src/Components/EditableArea.cs index 31940c08..7c94e8b9 100644 --- a/Tesserae/src/Components/EditableArea.cs +++ b/Tesserae/src/Components/EditableArea.cs @@ -5,16 +5,42 @@ namespace Tesserae.Components { - public sealed class EditableArea : ComponentBase, ITextFormating, IObservableComponent + public sealed class EditableArea : ComponentBase, ITextFormating, IBindableComponent { private event SaveEditHandler Saved; + public delegate bool SaveEditHandler(EditableArea sender, string newValue); - private readonly HTMLDivElement _container; + private readonly HTMLDivElement _container; private readonly HTMLSpanElement _labelText; - private readonly HTMLDivElement _editView; - private readonly HTMLDivElement _labelView; - private readonly SettableObservable _observable = new SettableObservable(); + private readonly HTMLDivElement _editView; + private readonly HTMLDivElement _labelView; + + + private SettableObservable _observable; + private ObservableEvent.ValueChanged valueGetter; + private bool _observableReferenceUsed = false; + + public SettableObservable Observable + { + get + { + _observableReferenceUsed = true; + return _observable; + } + set + { + if (_observableReferenceUsed) + { + throw new ArgumentException("Can't set the observable after a reference of it has been used! (.AsObservable() might have been called before .Bind())"); + } + + if (_observable is object) + _observable.StopObserving(valueGetter); + _observable = value; + _observable.Observe(valueGetter); + } + } private readonly HTMLElement _editIcon; private readonly HTMLElement _cancelEditIcon; @@ -24,15 +50,18 @@ public sealed class EditableArea : ComponentBase SetText(value); + Observable = new SettableObservable(text); + AttachChange(); AttachInput(); AttachFocus(); @@ -49,7 +78,7 @@ public EditableArea(string text = string.Empty) CancelEditing(); } }); - + OnBlur((_, __) => window.setTimeout(SaveEditing, 150)); // We need to do this on a timeout, because clicking on the Cancel would trigger this method first, with no opportunity to cancel } @@ -105,6 +134,7 @@ public TextAlign TextAlign { InnerElement.classList.remove(curFontSize); } + InnerElement.classList.add($"tss-textalign-{value.ToString().ToLower()}"); } } @@ -116,8 +146,8 @@ public bool IsEditingMode { if (value) { - var labelRect = (DOMRect)_labelText.getBoundingClientRect(); - InnerElement.style.minWidth = (labelRect.width * 1.2) + "px"; + var labelRect = (DOMRect) _labelText.getBoundingClientRect(); + InnerElement.style.minWidth = (labelRect.width * 1.2) + "px"; InnerElement.style.minHeight = (labelRect.height * 1.2) + "px"; _container.classList.add("tss-editing"); } @@ -137,14 +167,14 @@ public EditableArea OnSave(SaveEditHandler onSave) private void BeginEditing() { InnerElement.value = _labelText.textContent; - IsEditingMode = true; - _isCanceling = false; + IsEditingMode = true; + _isCanceling = false; InnerElement.focus(); } private void CancelEditing() { - _isCanceling = true; + _isCanceling = true; IsEditingMode = false; InnerElement.blur(); } @@ -160,8 +190,8 @@ private void SaveEditing(object e) if (Saved is null || Saved(this, newValue)) { _labelText.textContent = newValue; - _observable.Value = newValue; - IsEditingMode = false; + _observable.Value = newValue; + IsEditingMode = false; } else { @@ -170,7 +200,7 @@ private void SaveEditing(object e) } } - public EditableArea SetText(string text) + public EditableArea SetText(string text) { if (IsEditingMode) { @@ -187,7 +217,5 @@ public EditableArea SetText(string text) } public override HTMLElement Render() => _container; - - public IObservable AsObservable() => _observable; } } \ No newline at end of file diff --git a/Tesserae/src/Components/EditableLabel.cs b/Tesserae/src/Components/EditableLabel.cs index 92c8b198..acf6d81c 100644 --- a/Tesserae/src/Components/EditableLabel.cs +++ b/Tesserae/src/Components/EditableLabel.cs @@ -5,7 +5,7 @@ namespace Tesserae.Components { - public sealed class EditableLabel : ComponentBase, ITextFormating, IObservableComponent + public sealed class EditableLabel : ComponentBase, ITextFormating,IBindableComponent { private event SaveEditHandler Saved; public delegate bool SaveEditHandler(EditableLabel sender, string newValue); @@ -16,7 +16,31 @@ public sealed class EditableLabel : ComponentBase _observable = new SettableObservable(); + + private SettableObservable _observable; + private ObservableEvent.ValueChanged valueGetter; + private bool _observableReferenceUsed = false; + + public SettableObservable Observable + { + get + { + _observableReferenceUsed = true; + return _observable; + } + set + { + if (_observableReferenceUsed) + { + throw new ArgumentException("Can't set the observable after a reference of it has been used! (.AsObservable() might have been called before .Bind())"); + } + + if (_observable is object) + _observable.StopObserving(valueGetter); + _observable = value; + _observable.Observe(valueGetter); + } + } private bool _isCanceling = false; @@ -29,9 +53,12 @@ public EditableLabel(string text = string.Empty) InnerElement = TextBox(_("tss-editablelabel-textbox", type: "text")); _cancelEditIcon = Div(_("tss-editablelabel-cancel-icon", title: "Cancel edit"), I(_("las la-times"))); _editView = Div(_("tss-editablelabel-editbox"), InnerElement, _cancelEditIcon); - + _container = Div(_("tss-editablelabel"), _labelView, _editView); + valueGetter = value => SetText(value); + Observable = new SettableObservable(text); + AttachChange(); AttachInput(); AttachFocus(); @@ -50,6 +77,7 @@ public EditableLabel(string text = string.Empty) }); OnBlur((_, __) => BeginSaveEditing()); + } public TextSize Size @@ -184,6 +212,5 @@ public EditableLabel SetText(string text) public override HTMLElement Render() => _container; - public IObservable AsObservable() => _observable; } } \ No newline at end of file diff --git a/Tesserae/src/Components/IObservableComponent.cs b/Tesserae/src/Components/IObservableComponent.cs index 9262f37c..eb353e39 100644 --- a/Tesserae/src/Components/IObservableComponent.cs +++ b/Tesserae/src/Components/IObservableComponent.cs @@ -4,11 +4,51 @@ namespace Tesserae { public interface IObservableComponent { - IObservable AsObservable(); + IObservable Observable { get; } } public interface IObservableListComponent { - IObservable> AsObservable(); + IObservable> ObservableList { get; } + } + + public interface IBindableComponent + { + SettableObservable Observable { get; set; } + } + + public interface IBindableListComponent + { + ObservableList ObservableList { get; set; } + } + + public static class IObservableComponentExtension + { + public static IObservable AsObservable(this IObservableComponent component) + { + return component.Observable; + } + + public static IObservable> AsObservableList(this IObservableListComponent component) + { + return component.ObservableList; + } + + public static IObservable AsObservable(this IBindableComponent component) + { + return component.Observable; + } + + public static TComponent Bind(this TComponent component, SettableObservable observable) where TComponent : IBindableComponent + { + component.Observable = observable; + return component; + } + + public static TComponent Bind(this TComponent component, ObservableList observableList) where TComponent : IBindableListComponent + { + component.ObservableList = observableList; + return component; + } } } \ No newline at end of file diff --git a/Tesserae/src/Components/Input.cs b/Tesserae/src/Components/Input.cs index 983bba25..4a7cb23d 100644 --- a/Tesserae/src/Components/Input.cs +++ b/Tesserae/src/Components/Input.cs @@ -1,16 +1,44 @@ -using Tesserae.HTML; +using System; +using Tesserae.HTML; using static H5.Core.dom; using static Tesserae.UI; namespace Tesserae.Components { - public abstract class Input : ComponentBase, ICanValidate, IObservableComponent where TInput : Input + public abstract class Input : ComponentBase, ICanValidate where TInput : Input, IBindableComponent { - private readonly HTMLDivElement _container; + private readonly HTMLDivElement _container; private readonly HTMLSpanElement _errorSpan; - private readonly SettableObservable _observable = new SettableObservable(); - protected Input(string type, string defaultText = null) + + private SettableObservable _observable; + private ObservableEvent.ValueChanged valueGetter; + private bool _observableReferenceUsed = false; + + + public SettableObservable Observable + { + get + { + _observableReferenceUsed = true; + return _observable; + } + set + { + if (_observableReferenceUsed) + { + throw new ArgumentException("Can't set the observable after a reference of it has been used! (.AsObservable() might have been called before .Bind())"); + } + + if (_observable is object) + _observable.StopObserving(valueGetter); + _observable = value; + _observable.Observe(valueGetter); + } + } + + + protected Input(string type, string defaultText = String.Empty) { InnerElement = TextBox(_("tss-textbox", type: type, value: defaultText)); @@ -23,9 +51,14 @@ protected Input(string type, string defaultText = null) AttachBlur(); AttachKeys(); + valueGetter = value => Text = value; + Observable = new SettableObservable(defaultText); + // TODO: 27/06/20 - MB - calling virtual member within a constructor is a bit of a no-no. + // 05/08/20 - pius - ok for now since we do not implement these methods in a derived class + // @see https://stackoverflow.com/questions/119506/virtual-member-call-in-a-constructor OnChange((_, __) => _observable.Value = Text); - OnInput((_, __) => _observable.Value = Text); + OnInput((_, __) => _observable.Value = Text); } /// @@ -35,7 +68,7 @@ protected Input(string type, string defaultText = null) public void Reset() { InnerElement.value = ""; - _observable.Value = ""; + _observable.Value = ""; } public string Text @@ -44,7 +77,7 @@ public string Text set { InnerElement.value = value; - _observable.Value = value; + _observable.Value = value; RaiseOnInput(null); } } @@ -111,34 +144,37 @@ public void Attach(ComponentEventHandler handler) public TInput SetText(string text) { Text = text; - return (TInput)this; + return (TInput) this; } public TInput ClearText() { SetText(string.Empty); - return (TInput)this; + return (TInput) this; } public TInput Disabled(bool value = true) { IsEnabled = !value; - return (TInput)this; + return (TInput) this; } public TInput Required() { IsRequired = true; - return (TInput)this; + return (TInput) this; } public TInput Focus() { DomObserver.WhenMounted(InnerElement, () => window.setTimeout((_) => InnerElement.focus(), 500)); - return (TInput)this; + return (TInput) this; } - public IObservable AsObservable() => _observable; + public void SetObservable(SettableObservable observable) + { + Observable = observable; + } public override HTMLElement Render() => _container; } diff --git a/Tesserae/src/Components/MomentPickerBase.cs b/Tesserae/src/Components/MomentPickerBase.cs index fceed0f1..b1afa9c7 100644 --- a/Tesserae/src/Components/MomentPickerBase.cs +++ b/Tesserae/src/Components/MomentPickerBase.cs @@ -1,12 +1,10 @@ namespace Tesserae.Components { public abstract class MomentPickerBase : Input - where TMomentPicker : MomentPickerBase + where TMomentPicker : MomentPickerBase, IBindableComponent { protected MomentPickerBase(string type, string defaultText = null) - : base(type, defaultText) - { - } + : base(type, defaultText) { } protected TMoment Moment => FormatMoment(Text); @@ -31,23 +29,23 @@ public int Step public TMomentPicker SetMax(TMoment max) { Max = max; - return (TMomentPicker)this; + return (TMomentPicker) this; } public TMomentPicker SetMin(TMoment min) { Min = min; - return (TMomentPicker)this; + return (TMomentPicker) this; } public TMomentPicker SetStep(int step) { Step = step; - return (TMomentPicker)this; + return (TMomentPicker) this; } protected abstract string FormatMoment(TMoment moment); protected abstract TMoment FormatMoment(string moment); } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/MonthPicker.cs b/Tesserae/src/Components/MonthPicker.cs index 86e0aed5..7f17b3f7 100644 --- a/Tesserae/src/Components/MonthPicker.cs +++ b/Tesserae/src/Components/MonthPicker.cs @@ -3,12 +3,10 @@ namespace Tesserae.Components { - public class MonthPicker : MomentPickerBase + public class MonthPicker : MomentPickerBase, IBindableComponent { public MonthPicker((int year, int month)? monthAndYear) - : base("month", monthAndYear.HasValue ? FormatMonth(monthAndYear.Value) : string.Empty) - { - } + : base("month", monthAndYear.HasValue ? FormatMonth(monthAndYear.Value) : string.Empty) { } public (int year, int month) Month => Moment; @@ -30,7 +28,7 @@ public MonthPicker WithBrowserFallback() protected override (int year, int month) FormatMoment(string monthAndYear) { - var monthAndYearSplit = monthAndYear.Split(new []{ '-' }, StringSplitOptions.RemoveEmptyEntries); + var monthAndYearSplit = monthAndYear.Split(new[] {'-'}, StringSplitOptions.RemoveEmptyEntries); if (!monthAndYearSplit.Any() || monthAndYearSplit.Any(string.IsNullOrWhiteSpace)) { @@ -48,4 +46,4 @@ protected override (int year, int month) FormatMoment(string monthAndYear) return (yearParsed, monthParsed); } } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/ObservableComponentBase.cs b/Tesserae/src/Components/ObservableComponentBase.cs new file mode 100644 index 00000000..69c98c99 --- /dev/null +++ b/Tesserae/src/Components/ObservableComponentBase.cs @@ -0,0 +1,19 @@ +using H5; +using static H5.Core.dom; + +namespace Tesserae.Components +{ + public enum ObservableComponentUsageType + { + NoObservable, + BindableObservable, + ExportingObservable + } + + + public abstract class ObservableComponentBase : ComponentBase where T : ComponentBase where THTML : HTMLElement + { + private ObservableComponentUsageType _observableUsageType = ObservableComponentUsageType.NoObservable; + + } +} \ No newline at end of file diff --git a/Tesserae/src/Components/Picker.cs b/Tesserae/src/Components/Picker.cs index 3293c581..0b6a9d20 100644 --- a/Tesserae/src/Components/Picker.cs +++ b/Tesserae/src/Components/Picker.cs @@ -6,7 +6,7 @@ namespace Tesserae.Components { - public sealed class Picker : IComponent, IObservableListComponent where TPickerItem : class, IPickerItem + public sealed class Picker : IComponent where TPickerItem : class, IPickerItem { private event ComponentEventHandler, ItemPickedEvent> SelectedItem; @@ -46,11 +46,6 @@ public Picker(int maximumAllowedSelections = int.MaxValue, bool duplicateSelecti CreatePicker(pickerContainer); } - public IObservable> AsObservable() - { - return _pickerItems; - } - public IEnumerable PickerItems => _pickerItems; public IEnumerable SelectedPickerItems => _pickerItems.Where(pickerItem => pickerItem.IsSelected); diff --git a/Tesserae/src/Components/Slider.cs b/Tesserae/src/Components/Slider.cs index b7b07f54..88732a1e 100644 --- a/Tesserae/src/Components/Slider.cs +++ b/Tesserae/src/Components/Slider.cs @@ -1,23 +1,54 @@ -using static H5.Core.dom; +using System; +using static H5.Core.dom; using static Tesserae.UI; namespace Tesserae.Components { - public sealed class Slider : ComponentBase + public sealed class Slider : ComponentBase, IBindableComponent { private readonly HTMLLabelElement _outerLabel; - private readonly HTMLDivElement _outerDiv; - private readonly HTMLDivElement _fakeDiv; + private readonly HTMLDivElement _outerDiv; + private readonly HTMLDivElement _fakeDiv; + + + private SettableObservable _observable; + private ObservableEvent.ValueChanged valueGetter; + private bool _observableReferenceUsed = false; + + public SettableObservable Observable + { + get + { + _observableReferenceUsed = true; + return _observable; + } + set + { + if (_observableReferenceUsed) + { + throw new ArgumentException("Can't set the observable after a reference of it has been used! (.AsObservable() might have been called before .Bind())"); + } + + if (_observable is object) + _observable.StopObserving(valueGetter); + _observable = value; + _observable.Observe(valueGetter); + } + } + public Slider(int val = 0, int min = 0, int max = 100, int step = 10) { - InnerElement = document.createElement("input") as HTMLInputElement; + InnerElement = document.createElement("input") as HTMLInputElement; InnerElement.className = "tss-slider"; - InnerElement.value = val.ToString(); - InnerElement.min = min.ToString(); - InnerElement.max = max.ToString(); - InnerElement.step = step.ToString(); - InnerElement.type = "range"; + InnerElement.value = val.ToString(); + InnerElement.min = min.ToString(); + InnerElement.max = max.ToString(); + InnerElement.step = step.ToString(); + InnerElement.type = "range"; + + valueGetter = v => Value = v; + Observable = new SettableObservable(); AttachClick(); AttachChange(); @@ -28,11 +59,11 @@ public Slider(int val = 0, int min = 0, int max = 100, int step = 10) if (navigator.userAgent.IndexOf("AppleWebKit") != -1) { _fakeDiv = Div(_("tss-slider-fake-progress")); - double percent = ((double)(val - min) / (max - min)) * 100.0; + double percent = ((double) (val - min) / (max - min)) * 100.0; _fakeDiv.style.width = $"{percent:0.##}%"; InputUpdated += (e, s) => { - percent = ((double)(Value - Min) / (Max - Min)) * 100.0; + percent = ((double) (Value - Min) / (Max - Min)) * 100.0; _fakeDiv.style.width = $"{percent:0.##}%"; }; _outerLabel = Label(_("tss-slider-container"), InnerElement, Div(_("tss-slider-fake-background")), _fakeDiv); @@ -44,6 +75,9 @@ public Slider(int val = 0, int min = 0, int max = 100, int step = 10) } _outerDiv = Div(_("tss-slider-div"), _outerLabel); + + OnChange((_, __) => _observable.Value = Value); + OnInput((_, __) => _observable.Value = Value); } public SliderOrientation Orientation @@ -62,10 +96,20 @@ public SliderOrientation Orientation } } + private void SetBarWidth() + { + double percent = ((double) (Value - Min) / (Max - Min)) * 100.0; + _fakeDiv.style.width = $"{percent:0.##}%"; + } + public int Value { get => int.Parse(InnerElement.value); - set => InnerElement.value = value.ToString(); + set + { + InnerElement.value = value.ToString(); + RaiseOnInput(null); + } } public int Min @@ -73,6 +117,7 @@ public int Min get => int.Parse(InnerElement.min); set => InnerElement.min = value.ToString(); } + public int Max { get => int.Parse(InnerElement.max); @@ -111,16 +156,19 @@ public Slider SetValue(int val) Value = val; return this; } + public Slider SetMin(int min) { Min = min; return this; } + public Slider SetMax(int max) { Max = max; return this; } + public Slider SetStep(int step) { Step = step; @@ -151,4 +199,4 @@ public enum SliderOrientation Horizontal } } -} +} \ No newline at end of file diff --git a/Tesserae/src/Components/TextArea.cs b/Tesserae/src/Components/TextArea.cs index 6e31be81..4f8be5fd 100644 --- a/Tesserae/src/Components/TextArea.cs +++ b/Tesserae/src/Components/TextArea.cs @@ -1,24 +1,53 @@ -using Tesserae.HTML; +using System; +using Tesserae.HTML; using static H5.Core.dom; using static Tesserae.UI; + namespace Tesserae.Components { - public sealed class TextArea : ComponentBase, ICanValidate