Skip to content

Commit 81d4af7

Browse files
authored
Merge pull request #18 from nord-/claude/busy-allen-jucw81
Åtgärda fynd från kodgranskningen i #17
2 parents 558a5da + b331caa commit 81d4af7

13 files changed

Lines changed: 137 additions & 78 deletions

CLAUDE.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ No test project exists currently.
2525

2626
### Control Hierarchy
2727

28-
`LabelBase` is the base control — a Grid containing a Label, required indicator (`*`), a Border wrapping a generic View, and error message labels. Each concrete control inherits from `LabelBase` and composes a native MAUI control inside the Border:
28+
`LabelBase` is the base control — a Grid containing a label (with required indicator `*`) floating over a `NotchedBorder`, plus example and error message labels below. The label sits over the top edge of the border; `LabelBase` computes the notch coordinates so the stroke leaves a gap behind the label text. Each concrete control inherits from `LabelBase` and composes a native MAUI control inside the border:
2929

3030
- **LabeledEntry** → wraps `EntryBase` (platform-specific Entry)
31-
- **LabeledPicker** → wraps `Picker`
32-
- **LabeledDatePicker** → wraps `DatePicker`
33-
- **LabeledTimePicker** → wraps `TimePicker`
31+
- **LabeledPicker** → wraps `PickerBase`
32+
- **LabeledDatePicker** → wraps `DatePickerBase`
33+
- **LabeledTimePicker** → wraps `TimePickerBase`
34+
35+
`LabeledAutoCompleteEntry` is a separate `ContentView` that composes a `LabeledEntry` plus a suggestion dropdown (`CollectionView` in a `Border`), forwarding a subset of `LabeledEntry`'s properties.
36+
37+
### Drawing
38+
39+
`Drawing/NotchedBorder` is an internal-by-convention control (public but `[EditorBrowsable(Never)]`) that renders the outlined border via `Microsoft.Maui.Graphics` (`GraphicsView` + `IDrawable`). The pure path geometry lives in `Drawing/NotchedBorderDrawing.BuildPath`.
3440

3541
### Key Pattern: BindableProperty Proxying
3642

@@ -43,9 +49,9 @@ Each control proxies BindableProperties from the inner MAUI control to the outer
4349

4450
### Platform-Specific Code
4551

46-
`EntryBase` uses conditional compilation (`#if ANDROID` / `#if IOS`) to select `EntryBaseNative`, which applies platform-specific styling through MAUI handler mappers:
52+
`EntryBase` uses conditional compilation (`#if ANDROID` / `#if IOS`) to select `EntryBaseNative`; `PickerBase`/`DatePickerBase`/`TimePickerBase` use inline `#if` blocks. All apply platform-specific styling through MAUI handler mappers, filtered on the NiceEntry view type so consumer controls are unaffected:
4753
- **Android**: Transparent background on the underlying `AppCompatEditText`
48-
- **iOS**: No border style on the underlying `UITextField`
54+
- **iOS**: No border style on the underlying `UITextField`; the picker-style controls also override `MeasureOverride` to match the height of a borderless `UITextField` at the current font size (shared cache in `Base/NativeEntryHeight`)
4955

5056
### Validation
5157

NiceEntry/Base/DatePickerBase.cs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,26 @@ internal class DatePickerBase : DatePicker
1212
#if ANDROID
1313
static DatePickerBase()
1414
{
15-
DatePickerHandler.Mapper.AppendToMapping("NiceEntryDatePicker", (handler, _) =>
15+
DatePickerHandler.Mapper.AppendToMapping("NiceEntryDatePicker", (handler, view) =>
1616
{
17-
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
17+
if (view is DatePickerBase)
18+
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
1819
});
1920
}
2021
#elif IOS
21-
private static nfloat? _entryHeight;
22-
2322
static DatePickerBase()
2423
{
25-
DatePickerHandler.Mapper.AppendToMapping("NiceEntryDatePicker", (handler, _) =>
24+
DatePickerHandler.Mapper.AppendToMapping("NiceEntryDatePicker", (handler, view) =>
2625
{
27-
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
26+
if (view is DatePickerBase)
27+
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
2828
});
2929
}
3030

3131
protected override Size MeasureOverride(double widthConstraint, double heightConstraint)
3232
{
3333
var size = base.MeasureOverride(widthConstraint, heightConstraint);
34-
35-
if (_entryHeight is null)
36-
{
37-
using var reference = new UITextField { BorderStyle = UITextBorderStyle.None };
38-
var refSize = reference.SizeThatFits(new CoreGraphics.CGSize(nfloat.MaxValue, nfloat.MaxValue));
39-
_entryHeight = refSize.Height;
40-
}
41-
42-
return new Size(size.Width, (double)_entryHeight.Value);
34+
return new Size(size.Width, NativeEntryHeight.For(FontSize));
4335
}
4436
#endif
4537
}

NiceEntry/Base/LabelBase.xaml.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public LabelBase()
5151
}
5252

5353
// Existing properties
54-
public static readonly BindableProperty ViewProperty = BindableProperty.Create("View",
54+
public static readonly BindableProperty ViewProperty = BindableProperty.Create(nameof(View),
5555
typeof(View), typeof(LabelBase), defaultValue: null, defaultBindingMode: BindingMode.OneWay,
5656
propertyChanged: ElementChanged);
5757

@@ -132,6 +132,7 @@ private void UpdateElementView()
132132
_contentGrid.Insert(0, View);
133133
Grid.SetColumn((BindableObject)View, 0);
134134
BorderLabel.Content = _contentGrid;
135+
UpdateSemanticDescription();
135136
UpdateIsRequiredView();
136137
}
137138

@@ -154,9 +155,20 @@ private void UpdateLabelView()
154155
{
155156
LabelLabel.Text = Label;
156157
LabelLabel.IsVisible = !string.IsNullOrEmpty(Label);
158+
UpdateSemanticDescription();
157159
UpdateNotchBounds();
158160
}
159161

162+
// Forward the label text to the inner control so screen readers announce
163+
// what the input is for; the visual label is a sibling element and is not
164+
// associated with the input by the platform.
165+
private void UpdateSemanticDescription()
166+
{
167+
if (View is null) return;
168+
169+
SemanticProperties.SetDescription(View, Label);
170+
}
171+
160172
private void UpdateNotchBounds()
161173
{
162174
if (LabelContainer.Width <= 0 || !LabelLabel.IsVisible)
@@ -175,8 +187,12 @@ private void UpdateNotchBounds()
175187
private void UpdateErrorView()
176188
{
177189
var count = Error?.Count ?? 0;
178-
ErrorLabel.Text = count > 0 ? string.Join(',', Error!) : string.Empty;
190+
ErrorLabel.Text = count > 0 ? string.Join(", ", Error!) : string.Empty;
179191
ErrorLabel.IsVisible = count > 0;
192+
193+
if (count > 0)
194+
SemanticScreenReader.Announce(ErrorLabel.Text);
195+
180196
ChangeBorderColor();
181197
}
182198

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#if IOS
2+
using UIKit;
3+
4+
namespace NiceEntry;
5+
6+
/// <summary>
7+
/// Measures the height of a borderless <see cref="UITextField"/> so the
8+
/// picker-style inputs match the height of <c>EntryBase</c> at the same
9+
/// font size. Results are cached per font size.
10+
/// </summary>
11+
internal static class NativeEntryHeight
12+
{
13+
private static readonly Dictionary<double, double> Cache = new();
14+
15+
public static double For(double fontSize)
16+
{
17+
var key = fontSize > 0 ? fontSize : 0;
18+
if (Cache.TryGetValue(key, out var height)) return height;
19+
20+
using var reference = new UITextField { BorderStyle = UITextBorderStyle.None };
21+
if (key > 0)
22+
reference.Font = UIFont.SystemFontOfSize((nfloat)key);
23+
24+
var size = reference.SizeThatFits(new CoreGraphics.CGSize(nfloat.MaxValue, nfloat.MaxValue));
25+
Cache[key] = size.Height;
26+
return size.Height;
27+
}
28+
}
29+
#endif

NiceEntry/Base/PickerBase.cs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,26 @@ internal class PickerBase : Picker
1212
#if ANDROID
1313
static PickerBase()
1414
{
15-
PickerHandler.Mapper.AppendToMapping("NiceEntryPicker", (handler, _) =>
15+
PickerHandler.Mapper.AppendToMapping("NiceEntryPicker", (handler, view) =>
1616
{
17-
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
17+
if (view is PickerBase)
18+
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
1819
});
1920
}
2021
#elif IOS
21-
private static nfloat? _entryHeight;
22-
2322
static PickerBase()
2423
{
25-
PickerHandler.Mapper.AppendToMapping("NiceEntryPicker", (handler, _) =>
24+
PickerHandler.Mapper.AppendToMapping("NiceEntryPicker", (handler, view) =>
2625
{
27-
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
26+
if (view is PickerBase)
27+
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
2828
});
2929
}
3030

3131
protected override Size MeasureOverride(double widthConstraint, double heightConstraint)
3232
{
3333
var size = base.MeasureOverride(widthConstraint, heightConstraint);
34-
35-
if (_entryHeight is null)
36-
{
37-
using var reference = new UITextField { BorderStyle = UITextBorderStyle.None };
38-
var refSize = reference.SizeThatFits(new CoreGraphics.CGSize(nfloat.MaxValue, nfloat.MaxValue));
39-
_entryHeight = refSize.Height;
40-
}
41-
42-
return new Size(size.Width, (double)_entryHeight.Value);
34+
return new Size(size.Width, NativeEntryHeight.For(FontSize));
4335
}
4436
#endif
4537
}

NiceEntry/Base/TimePickerBase.cs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,26 @@ internal class TimePickerBase : TimePicker
1212
#if ANDROID
1313
static TimePickerBase()
1414
{
15-
TimePickerHandler.Mapper.AppendToMapping("NiceEntryTimePicker", (handler, _) =>
15+
TimePickerHandler.Mapper.AppendToMapping("NiceEntryTimePicker", (handler, view) =>
1616
{
17-
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
17+
if (view is TimePickerBase)
18+
handler.PlatformView.SetBackgroundColor(global::Android.Graphics.Color.Transparent);
1819
});
1920
}
2021
#elif IOS
21-
private static nfloat? _entryHeight;
22-
2322
static TimePickerBase()
2423
{
25-
TimePickerHandler.Mapper.AppendToMapping("NiceEntryTimePicker", (handler, _) =>
24+
TimePickerHandler.Mapper.AppendToMapping("NiceEntryTimePicker", (handler, view) =>
2625
{
27-
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
26+
if (view is TimePickerBase)
27+
handler.PlatformView.BorderStyle = UITextBorderStyle.None;
2828
});
2929
}
3030

3131
protected override Size MeasureOverride(double widthConstraint, double heightConstraint)
3232
{
3333
var size = base.MeasureOverride(widthConstraint, heightConstraint);
34-
35-
if (_entryHeight is null)
36-
{
37-
using var reference = new UITextField { BorderStyle = UITextBorderStyle.None };
38-
var refSize = reference.SizeThatFits(new CoreGraphics.CGSize(nfloat.MaxValue, nfloat.MaxValue));
39-
_entryHeight = refSize.Height;
40-
}
41-
42-
return new Size(size.Width, (double)_entryHeight.Value);
34+
return new Size(size.Width, NativeEntryHeight.For(FontSize));
4335
}
4436
#endif
4537
}

NiceEntry/LabeledAutoCompleteEntry.xaml.cs

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ public LabeledAutoCompleteEntry()
2929
Entry.Element.Focused += OnEntryFocused;
3030
Entry.Element.Unfocused += OnEntryUnfocused;
3131

32+
// Detach the CollectionChanged subscription while unloaded so a
33+
// long-lived Suggestions collection doesn't keep this control (and
34+
// its page) alive after navigation.
35+
Loaded += (_, _) =>
36+
{
37+
AttachObservedSuggestions();
38+
RebuildVisibleSuggestions();
39+
};
40+
Unloaded += (_, _) => DetachObservedSuggestions();
41+
3242
SuggestionsView.ItemTemplate = DefaultSuggestionTemplate;
3343
}
3444

@@ -67,7 +77,7 @@ public LabeledAutoCompleteEntry()
6777
propertyChanged: (b, _, n) => ((LabeledAutoCompleteEntry)b).Entry.Keyboard = (Keyboard)n);
6878

6979
public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create(
70-
nameof(MaxLength), typeof(int), typeof(LabeledAutoCompleteEntry), defaultValue: -1,
80+
nameof(MaxLength), typeof(int), typeof(LabeledAutoCompleteEntry), defaultValue: int.MaxValue,
7181
propertyChanged: (b, _, n) => ((LabeledAutoCompleteEntry)b).Entry.MaxLength = (int)n);
7282

7383
public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create(
@@ -121,19 +131,27 @@ private static void SuggestionsChanged(BindableObject bindable, object oldValue,
121131
{
122132
var self = (LabeledAutoCompleteEntry)bindable;
123133

124-
if (self._observedSuggestions is not null)
125-
{
126-
self._observedSuggestions.CollectionChanged -= self.OnSuggestionsCollectionChanged;
127-
self._observedSuggestions = null;
128-
}
134+
self.DetachObservedSuggestions();
135+
self.AttachObservedSuggestions();
136+
self.RebuildVisibleSuggestions();
137+
}
129138

130-
if (newValue is INotifyCollectionChanged notify)
131-
{
132-
self._observedSuggestions = notify;
133-
notify.CollectionChanged += self.OnSuggestionsCollectionChanged;
134-
}
139+
private void AttachObservedSuggestions()
140+
{
141+
if (_observedSuggestions is not null) return;
142+
if (!IsLoaded) return;
143+
if (Suggestions is not INotifyCollectionChanged notify) return;
135144

136-
self.RebuildVisibleSuggestions();
145+
_observedSuggestions = notify;
146+
notify.CollectionChanged += OnSuggestionsCollectionChanged;
147+
}
148+
149+
private void DetachObservedSuggestions()
150+
{
151+
if (_observedSuggestions is null) return;
152+
153+
_observedSuggestions.CollectionChanged -= OnSuggestionsCollectionChanged;
154+
_observedSuggestions = null;
137155
}
138156

139157
private void OnSuggestionsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)

NiceEntry/LabeledDatePicker.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public LabeledDatePicker()
1313
UpdateFontSizeView();
1414
}
1515

16-
public static readonly BindableProperty DateProperty = BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(LabeledDatePicker), propertyChanged: DateChanged, defaultBindingMode: BindingMode.TwoWay);
16+
public static readonly BindableProperty DateProperty = BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(LabeledDatePicker), propertyChanged: DateChanged, defaultBindingMode: BindingMode.TwoWay, defaultValueCreator: static _ => DateTime.Today);
1717
public static readonly BindableProperty MinimumDateProperty = BindableProperty.Create(nameof(MinimumDate), typeof(DateTime), typeof(LabeledDatePicker), DateTime.MinValue, propertyChanged: MinimumDateChanged);
1818
public static readonly BindableProperty MaximumDateProperty = BindableProperty.Create(nameof(MaximumDate), typeof(DateTime), typeof(LabeledDatePicker), DateTime.MaxValue, propertyChanged: MaximumDateChanged);
1919
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(double), typeof(LabeledDatePicker), LabelBase.DefaultFontSize, propertyChanged: FontSizeChanged);

NiceEntry/LabeledEntry.xaml.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ public LabeledEntry()
1212
Element.SetBinding(Entry.TextProperty, nameof(Text), BindingMode.TwoWay);
1313
Element.BindingContext = this;
1414

15-
Element.Focused += (_, _) =>
16-
{
17-
Element.CursorPosition = 0;
18-
Element.SelectionLength = Element.Text?.Length ?? 0;
19-
};
15+
Element.Focused += OnElementFocused;
2016

2117
UpdateFontSizeView();
2218
UpdatePlaceholderColorView();
@@ -25,14 +21,15 @@ public LabeledEntry()
2521
public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(LabeledEntry), propertyChanged: TextChanged, defaultBindingMode: BindingMode.TwoWay);
2622
public static readonly BindableProperty PlaceholderProperty = BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(LabeledEntry), propertyChanged: PlaceholderChanged);
2723
public static readonly BindableProperty PlaceholderColorProperty = BindableProperty.Create(nameof(PlaceholderColor), typeof(Color), typeof(LabeledEntry), Color.FromArgb("#808080"), propertyChanged: PlaceholderColorChanged);
28-
public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create(nameof(MaxLength), typeof(int), typeof(int), int.MaxValue, propertyChanged: MaxLengthChanged);
24+
public static readonly BindableProperty MaxLengthProperty = BindableProperty.Create(nameof(MaxLength), typeof(int), typeof(LabeledEntry), int.MaxValue, propertyChanged: MaxLengthChanged);
2925
public static readonly BindableProperty ReturnTypeProperty = BindableProperty.Create(nameof(ReturnType), typeof(ReturnType), typeof(LabeledEntry), propertyChanged: ReturnTypeChanged);
3026
public static readonly BindableProperty KeyboardProperty = BindableProperty.Create(nameof(Keyboard), typeof(Keyboard), typeof(LabeledEntry), propertyChanged: KeyboardChanged);
3127
public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(LabeledEntry), false, propertyChanged: IsPasswordChanged);
3228
public static readonly BindableProperty IsReadOnlyProperty = BindableProperty.Create(nameof(IsReadOnly), typeof(bool), typeof(LabeledEntry), false, propertyChanged: IsReadOnlyChanged);
3329
public static readonly BindableProperty ReturnCommandProperty = BindableProperty.Create(nameof(ReturnCommand), typeof(ICommand), typeof(LabeledEntry), propertyChanged: ReturnCommandChanged);
3430
public static readonly BindableProperty HorizontalTextAlignmentProperty = BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(LabeledEntry), TextAlignment.Start, propertyChanged: HorizontalTextAlignmentChanged);
3531
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(double), typeof(LabeledEntry), LabelBase.DefaultFontSize, propertyChanged: FontSizeChanged);
32+
public static readonly BindableProperty SelectAllOnFocusProperty = BindableProperty.Create(nameof(SelectAllOnFocus), typeof(bool), typeof(LabeledEntry), true);
3633

3734
public string Text
3835
{
@@ -99,7 +96,21 @@ public double FontSize
9996
get => (double)GetValue(FontSizeProperty);
10097
set => SetValue(FontSizeProperty, value);
10198
}
102-
99+
100+
public bool SelectAllOnFocus
101+
{
102+
get => (bool)GetValue(SelectAllOnFocusProperty);
103+
set => SetValue(SelectAllOnFocusProperty, value);
104+
}
105+
106+
private void OnElementFocused(object? sender, FocusEventArgs e)
107+
{
108+
if (!SelectAllOnFocus) return;
109+
110+
Element.CursorPosition = 0;
111+
Element.SelectionLength = Element.Text?.Length ?? 0;
112+
}
113+
103114
private static void TextChanged(BindableObject bindable, object oldValue, object newValue) => ((LabeledEntry)bindable).UpdateTextView();
104115
private static void PlaceholderChanged(BindableObject bindable, object oldValue, object newValue) => ((LabeledEntry)bindable).UpdatePlaceholderView();
105116
private static void PlaceholderColorChanged(BindableObject bindable, object oldValue, object newValue) => ((LabeledEntry)bindable).UpdatePlaceholderColorView();

NiceEntry/LabeledPicker.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public LabeledPicker()
1919
}
2020

2121
public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(LabeledPicker), propertyChanged: ItemSourceChanged, defaultBindingMode: BindingMode.TwoWay);
22-
public static readonly BindableProperty SelectedIndexProperty = BindableProperty.Create(nameof(SelectedIndex), typeof(int), typeof(LabeledPicker), propertyChanged: SelectedIndexChanged, defaultBindingMode: BindingMode.TwoWay);
22+
public static readonly BindableProperty SelectedIndexProperty = BindableProperty.Create(nameof(SelectedIndex), typeof(int), typeof(LabeledPicker), -1, propertyChanged: SelectedIndexChanged, defaultBindingMode: BindingMode.TwoWay);
2323
public static readonly BindableProperty SelectedItemProperty = BindableProperty.Create(nameof(SelectedItem), typeof(object), typeof(LabeledPicker), propertyChanged: SelectedItemChanged, defaultBindingMode: BindingMode.TwoWay);
2424
public static readonly BindableProperty PlaceholderProperty = BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(LabeledPicker), propertyChanged: PlaceholderChanged);
2525
public static readonly BindableProperty TitleColorProperty = BindableProperty.Create(nameof(TitleColor), typeof(Color), typeof(LabeledPicker), Color.FromArgb("#808080"), propertyChanged: TitleColorChanged);

0 commit comments

Comments
 (0)