diff --git a/src/Ivy.Samples.Shared/Apps/Tests/InputAffixesGalleryApp.cs b/src/Ivy.Samples.Shared/Apps/Tests/InputAffixesGalleryApp.cs new file mode 100644 index 0000000000..23faec3215 --- /dev/null +++ b/src/Ivy.Samples.Shared/Apps/Tests/InputAffixesGalleryApp.cs @@ -0,0 +1,901 @@ +namespace Ivy.Samples.Shared.Apps.Tests; + +using static InputAffixesGalleryHelpers; + +[App( + icon: Icons.TextCursorInput, + group: ["Tests"], + isVisible: true, + searchHints: ["affix", "prefix", "suffix", "input", "bool", "color", "code", "date", "datetime", "feedback", "icon", "kbd", "shortcut", "density"])] +public class InputAffixesGalleryApp : SampleBase +{ + protected override object? BuildSample() => + Layout.Vertical() + | Text.H1("Input Affixes Gallery") + | Layout.Tabs( + new Tab("All inputs", new InputAffixesAllInputsView()), + new Tab("Text variants", new InputAffixesTextVariantsView()), + new Tab("Densities", new InputAffixesDensitiesView()), + new Tab("Number inputs", new InputAffixesNumberView()), + new Tab("Select inputs", new InputAffixesSelectView()), + new Tab("Bool inputs", new InputAffixesBoolView()), + new Tab("Color inputs", new InputAffixesColorView()), + new Tab("Feedback inputs", new InputAffixesFeedbackView()), + new Tab("Icon inputs", new InputAffixesIconView()), + new Tab("Code inputs", new InputAffixesCodeView()), + new Tab("DateTime inputs", new InputAffixesDateTimeView()), + new Tab("Date range inputs", new InputAffixesDateRangeView()) + ).Variant(TabsVariant.Content); +} + +public class InputAffixesAllInputsView : ViewBase +{ + public override object Build() + { + var textState = UseState("ivy.app"); + var passwordState = UseState("secret"); + var searchState = UseState("ivy.app"); + var emailState = UseState("user@ivy.app"); + var telState = UseState("5550100"); + var urlState = UseState("ivy.app"); + var textareaState = UseState("Notes"); + var numberState = UseState(42.5m); + var currencyState = UseState(Currency.USD); + var dateState = UseState(DateTime.Now); + var rangeState = UseState((DateOnly.FromDateTime(DateTime.Today), DateOnly.FromDateTime(DateTime.Today.AddDays(7)))); + var boolState = UseState(true); + var colorState = UseState("#6366f1"); + var feedbackState = UseState(3); + var iconState = UseState(Icons.Heart); + var codeState = UseState("console.log('ivy');"); + var shortcutTextState = UseState((string?)null); + var shortcutPasswordState = UseState((string?)null); + var shortcutSearchState = UseState((string?)null); + var shortcutEmailState = UseState((string?)null); + var shortcutTelState = UseState((string?)null); + var shortcutUrlState = UseState((string?)null); + var shortcutTextareaState = UseState((string?)null); + var currencyOptions = typeof(Currency).ToOptions(); + + return Layout.Vertical() + | Callout.Info( + "Compare prefix, suffix, and both affixes across inputs with transparent affix chrome. Shortcut-key rows use empty nullable fields so the kbd hint is visible beside suffix affixes.") + | Text.H2("Text inputs") + | AffixHeaderRow() + | AffixRow("Text", textState.ToTextInput().Prefix(Icons.Link), textState.ToTextInput().Suffix(Icons.Globe), textState.ToTextInput().Prefix(Icons.Link).Suffix(Icons.Globe)) + | AffixRow("Password", passwordState.ToPasswordInput().Prefix(Icons.Lock), passwordState.ToPasswordInput().Suffix(Icons.Key), passwordState.ToPasswordInput().Prefix(Icons.Lock).Suffix(Icons.Shield)) + | AffixRow("Search", searchState.ToSearchInput().Prefix(Icons.ListFilterPlus).Placeholder("Search..."), searchState.ToSearchInput().Suffix(Icons.Tag).Placeholder("Search..."), searchState.ToSearchInput().Prefix(Icons.Folder).Suffix(Icons.Globe).Placeholder("Search...")) + | AffixRow("Email", emailState.ToEmailInput().Prefix(Icons.Mail), emailState.ToEmailInput().Suffix(Icons.AtSign), emailState.ToEmailInput().Prefix(Icons.Mail).Suffix(Icons.AtSign)) + | AffixRow("Tel", telState.ToTelInput().Prefix(Icons.Phone), telState.ToTelInput().Suffix(Icons.Hash), telState.ToTelInput().Prefix(Icons.Phone).Suffix(Icons.Hash)) + | AffixRow("Url", urlState.ToUrlInput().Prefix(Icons.Link), urlState.ToUrlInput().Suffix(Icons.ExternalLink), urlState.ToUrlInput().Prefix(Icons.Link).Suffix(Icons.ExternalLink)) + | AffixRow("Textarea", textareaState.ToTextareaInput().Prefix(Icons.FileText), textareaState.ToTextareaInput().Suffix(Icons.Type), textareaState.ToTextareaInput().Prefix(Icons.FileText).Suffix(Icons.Type)) + | Text.H2("Shortcut keys with affixes") + | AffixHeaderRow() + | AffixRow( + "Text", + shortcutTextState.ToTextInput().Nullable().Prefix(Icons.Link).ShortcutKey("Ctrl+/").Placeholder("Path"), + shortcutTextState.ToTextInput().Nullable().Suffix(Icons.Globe).ShortcutKey("Ctrl+/").Placeholder("Domain"), + shortcutTextState.ToTextInput().Nullable().Prefix(Icons.Link).Suffix(Icons.Globe).ShortcutKey("Ctrl+/").Placeholder("URL")) + | AffixRow( + "Password", + shortcutPasswordState.ToPasswordInput().Nullable().Prefix(Icons.Lock).ShortcutKey("Ctrl+L").Placeholder("Password"), + shortcutPasswordState.ToPasswordInput().Nullable().Suffix(Icons.Key).ShortcutKey("Ctrl+L").Placeholder("Password"), + shortcutPasswordState.ToPasswordInput().Nullable().Prefix(Icons.Lock).Suffix(Icons.Shield).ShortcutKey("Ctrl+L").Placeholder("Password")) + | AffixRow( + "Search", + shortcutSearchState.ToSearchInput().Nullable().Prefix(Icons.ListFilterPlus).ShortcutKey("Ctrl+K").Placeholder("Search..."), + shortcutSearchState.ToSearchInput().Nullable().Suffix(Icons.Tag).ShortcutKey("Ctrl+K").Placeholder("Search..."), + shortcutSearchState.ToSearchInput().Nullable().Prefix(Icons.Folder).Suffix(Icons.Globe).ShortcutKey("Ctrl+K").Placeholder("Search...")) + | AffixRow( + "Email", + shortcutEmailState.ToEmailInput().Nullable().Prefix(Icons.Mail).ShortcutKey("Ctrl+E").Placeholder("Email"), + shortcutEmailState.ToEmailInput().Nullable().Suffix(Icons.AtSign).ShortcutKey("Ctrl+E").Placeholder("Email"), + shortcutEmailState.ToEmailInput().Nullable().Prefix(Icons.Mail).Suffix(Icons.AtSign).ShortcutKey("Ctrl+E").Placeholder("Email")) + | AffixRow( + "Tel", + shortcutTelState.ToTelInput().Nullable().Prefix(Icons.Phone).ShortcutKey("Ctrl+J").Placeholder("Phone"), + shortcutTelState.ToTelInput().Nullable().Suffix(Icons.Hash).ShortcutKey("Ctrl+J").Placeholder("Extension"), + shortcutTelState.ToTelInput().Nullable().Prefix(Icons.Phone).Suffix(Icons.Hash).ShortcutKey("Ctrl+J").Placeholder("Phone")) + | AffixRow( + "Url", + shortcutUrlState.ToUrlInput().Nullable().Prefix(Icons.Link).ShortcutKey("Ctrl+U").Placeholder("URL"), + shortcutUrlState.ToUrlInput().Nullable().Suffix(Icons.ExternalLink).ShortcutKey("Ctrl+U").Placeholder("URL"), + shortcutUrlState.ToUrlInput().Nullable().Prefix(Icons.Link).Suffix(Icons.ExternalLink).ShortcutKey("Ctrl+U").Placeholder("URL")) + | AffixRow( + "Textarea", + shortcutTextareaState.ToTextareaInput().Nullable().Prefix(Icons.FileText).ShortcutKey("Ctrl+T").Placeholder("Notes"), + shortcutTextareaState.ToTextareaInput().Nullable().Suffix(Icons.Type).ShortcutKey("Ctrl+T").Placeholder("Notes"), + shortcutTextareaState.ToTextareaInput().Nullable().Prefix(Icons.FileText).Suffix(Icons.Type).ShortcutKey("Ctrl+T").Placeholder("Notes")) + | Text.H2("Other inputs") + | AffixHeaderRow() + | AffixRow("Number", numberState.ToNumberInput().Prefix(Icons.DollarSign).Precision(1), numberState.ToNumberInput().Suffix(Icons.Percent).Precision(1), numberState.ToNumberInput().Prefix(Icons.DollarSign).Suffix(Icons.Coins).Precision(1)) + | AffixRow("Select", currencyState.ToSelectInput(currencyOptions).Prefix(Icons.DollarSign), currencyState.ToSelectInput(currencyOptions).Suffix(Icons.BadgeDollarSign), currencyState.ToSelectInput(currencyOptions).Prefix(Icons.DollarSign).Suffix(Icons.BadgeDollarSign)) + | AffixRow( + "DateTime", + DateAffixDemo(dateState).Prefix(Icons.Calendar), + DateAffixDemo(dateState).Suffix(Icons.Clock), + DateAffixDemo(dateState).Prefix(Icons.Calendar).Suffix(Icons.Clock)) + | AffixRow( + "Date range", + DateRangeAffixDemo(rangeState).Prefix(Icons.CalendarRange), + DateRangeAffixDemo(rangeState).Suffix(Icons.CalendarDays), + DateRangeAffixDemo(rangeState).Prefix(Icons.CalendarRange).Suffix(Icons.CalendarDays)) + | AffixRow("Bool", boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell), boolState.ToBoolInput().Label("Enable").Suffix(Icons.BadgeQuestionMark), boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell).Suffix(Icons.BadgeQuestionMark)) + | AffixRow("Color", colorState.ToColorInput().Prefix(Icons.Palette), colorState.ToColorInput().Suffix(Icons.Pipette), colorState.ToColorInput().Prefix(Icons.Palette).Suffix(Icons.Pipette)) + | AffixRow("Feedback", feedbackState.ToFeedbackInput().Stars().Prefix(Icons.Star), feedbackState.ToFeedbackInput().Stars().Suffix(Icons.MessageSquare), feedbackState.ToFeedbackInput().Stars().Prefix(Icons.Star).Suffix(Icons.MessageSquare)) + | AffixRow("Icon", iconState.ToIconInput().Prefix(Icons.Search), iconState.ToIconInput().Suffix(Icons.Sparkles), iconState.ToIconInput().Prefix(Icons.Search).Suffix(Icons.Sparkles)) + | AffixRow( + "Code", + CodeAffixDemo(codeState).Prefix(Icons.Code), + CodeAffixDemo(codeState).Suffix(Icons.Braces), + CodeAffixDemo(codeState).Prefix(Icons.Code).Suffix(Icons.Braces)); + } + + private enum Currency + { + USD, + EUR, + GBP, + } +} + +public class InputAffixesTextVariantsView : ViewBase +{ + public override object Build() + { + var textState = UseState("ivy.app"); + var passwordState = UseState("secret"); + var searchState = UseState("ivy.app"); + var emailState = UseState("user@ivy.app"); + var telState = UseState("5550100"); + var urlState = UseState("ivy.app"); + var textareaState = UseState("Notes"); + + return Layout.Vertical() + | Callout.Info( + "Compare trailing controls (password eye, search clear, nullable clear) with suffix affixes. Eye and clear buttons should sit in the same column between field and suffix across variants.") + | AffixHeaderRow() + | AffixRow( + "Text", + textState.ToTextInput().Nullable().Prefix(Icons.Link), + textState.ToTextInput().Nullable().Suffix(Icons.Globe), + textState.ToTextInput().Nullable().Prefix(Icons.Link).Suffix(Icons.Globe)) + | AffixRow( + "Password", + passwordState.ToPasswordInput().Prefix(Icons.Lock), + passwordState.ToPasswordInput().Suffix(Icons.Key), + passwordState.ToPasswordInput().Prefix(Icons.Lock).Suffix(Icons.Shield)) + | AffixRow( + "Search", + searchState.ToSearchInput().Prefix(Icons.ListFilterPlus).Placeholder("Search..."), + searchState.ToSearchInput().Suffix(Icons.Tag).Placeholder("Search..."), + searchState.ToSearchInput().Prefix(Icons.Folder).Suffix(Icons.Globe).Placeholder("Search...")) + | AffixRow( + "Email", + emailState.ToEmailInput().Prefix(Icons.Mail), + emailState.ToEmailInput().Suffix(Icons.AtSign), + emailState.ToEmailInput().Prefix(Icons.Mail).Suffix(Icons.AtSign)) + | AffixRow( + "Tel", + telState.ToTelInput().Prefix(Icons.Phone), + telState.ToTelInput().Suffix(Icons.Hash), + telState.ToTelInput().Prefix(Icons.Phone).Suffix(Icons.Hash)) + | AffixRow( + "Url", + urlState.ToUrlInput().Prefix(Icons.Link), + urlState.ToUrlInput().Suffix(Icons.ExternalLink), + urlState.ToUrlInput().Prefix(Icons.Link).Suffix(Icons.ExternalLink)) + | AffixRow( + "Textarea", + textareaState.ToTextareaInput().Nullable().Prefix(Icons.FileText), + textareaState.ToTextareaInput().Nullable().Suffix(Icons.Type), + textareaState.ToTextareaInput().Nullable().Prefix(Icons.FileText).Suffix(Icons.Type)); + } +} + +public class InputAffixesNumberView : ViewBase +{ + public override object Build() + { + var numberState = UseState(42.5m); + var nullableState = UseState(() => null); + var invalidState = UseState(-5m); + + return Layout.Vertical() + | Callout.Info( + "Number input affixes: icon size matches trailing invalid/clear controls; symmetric padding around prefix/suffix icons.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Number", + numberState.ToNumberInput().Precision(1).Prefix(Icons.DollarSign), + numberState.ToNumberInput().Precision(1).Suffix(Icons.Percent), + numberState.ToNumberInput().Precision(1).Prefix(Icons.DollarSign).Suffix(Icons.Coins)) + | AffixRow( + "Nullable", + nullableState.ToNumberInput().Nullable().Precision(1).Prefix(Icons.DollarSign), + nullableState.ToNumberInput().Nullable().Precision(1).Suffix(Icons.Percent), + nullableState.ToNumberInput().Nullable().Precision(1).Prefix(Icons.DollarSign).Suffix(Icons.Coins)) + | AffixRow( + "Invalid", + invalidState.ToNumberInput().Precision(0).Prefix(Icons.DollarSign).Invalid("Must be positive"), + invalidState.ToNumberInput().Precision(0).Suffix(Icons.Percent).Invalid("Must be positive"), + invalidState + .ToNumberInput() + .Precision(0) + .Prefix(Icons.DollarSign) + .Suffix(Icons.Coins) + .Invalid("Must be positive")) + | AffixRow( + "Nullable + invalid", + nullableState.ToNumberInput().Nullable().Precision(1).Prefix(Icons.DollarSign).Invalid("Required"), + nullableState.ToNumberInput().Nullable().Precision(1).Suffix(Icons.Percent).Invalid("Required"), + nullableState + .ToNumberInput() + .Nullable() + .Precision(1) + .Prefix(Icons.DollarSign) + .Suffix(Icons.Coins) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both prefix and suffix with nullable clear at Small, Medium, and Large density.") + | DensityHeaderRow() + | NumberDensityRow( + "Number", + numberState.ToNumberInput().Nullable().Precision(1).Prefix(Icons.DollarSign).Suffix(Icons.Percent)) + | NumberDensityRow( + "Both + invalid", + invalidState + .ToNumberInput() + .Nullable() + .Precision(0) + .Prefix(Icons.DollarSign) + .Suffix(Icons.Coins) + .Invalid("Must be positive")); + } +} + +public class InputAffixesSelectView : ViewBase +{ + public override object Build() + { + var currencyState = UseState(Currency.USD); + var nullableState = UseState(() => null); + var currencyOptions = typeof(Currency).ToOptions(); + + return Layout.Vertical() + | Callout.Info( + "Select affixes: clear and invalid sit beside the suffix icon (not inside the trigger); icon glyphs match trailing control size.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Select", + currencyState.ToSelectInput(currencyOptions).Prefix(Icons.DollarSign), + currencyState.ToSelectInput(currencyOptions).Suffix(Icons.BadgeDollarSign), + currencyState.ToSelectInput(currencyOptions).Prefix(Icons.DollarSign).Suffix(Icons.BadgeDollarSign)) + | AffixRow( + "Nullable", + nullableState.ToSelectInput(currencyOptions).Nullable().Prefix(Icons.DollarSign), + nullableState.ToSelectInput(currencyOptions).Nullable().Suffix(Icons.BadgeDollarSign), + nullableState + .ToSelectInput(currencyOptions) + .Nullable() + .Prefix(Icons.DollarSign) + .Suffix(Icons.BadgeDollarSign)) + | AffixRow( + "Invalid", + currencyState.ToSelectInput(currencyOptions).Prefix(Icons.DollarSign).Invalid("Required"), + currencyState.ToSelectInput(currencyOptions).Suffix(Icons.BadgeDollarSign).Invalid("Required"), + currencyState + .ToSelectInput(currencyOptions) + .Prefix(Icons.DollarSign) + .Suffix(Icons.BadgeDollarSign) + .Invalid("Required")) + | AffixRow( + "Nullable + invalid", + nullableState.ToSelectInput(currencyOptions).Nullable().Prefix(Icons.DollarSign).Invalid("Required"), + nullableState.ToSelectInput(currencyOptions).Nullable().Suffix(Icons.BadgeDollarSign).Invalid("Required"), + nullableState + .ToSelectInput(currencyOptions) + .Nullable() + .Prefix(Icons.DollarSign) + .Suffix(Icons.BadgeDollarSign) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both prefix and suffix with nullable clear at Small, Medium, and Large density.") + | DensityHeaderRow() + | SelectDensityRow( + "Select", + currencyState.ToSelectInput(currencyOptions).Nullable().Prefix(Icons.DollarSign).Suffix(Icons.BadgeDollarSign)) + | SelectDensityRow( + "Both + invalid", + currencyState + .ToSelectInput(currencyOptions) + .Nullable() + .Prefix(Icons.DollarSign) + .Suffix(Icons.BadgeDollarSign) + .Invalid("Required")); + } + + private enum Currency + { + USD, + EUR, + GBP, + } +} + +public class InputAffixesBoolView : ViewBase +{ + public override object Build() + { + var boolState = UseState(true); + var nullableState = UseState(() => null); + + return Layout.Vertical() + | Callout.Info( + "Bool affixes: prefix/suffix icon scale matches number/select; invalid sits in the suffix cluster beside the suffix glyph (not on the checkbox). Use this tab to verify gaps, density, and invalid+suffix layout.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Checkbox", + boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell), + boolState.ToBoolInput().Label("Enable").Suffix(Icons.BadgeQuestionMark), + boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell).Suffix(Icons.BadgeQuestionMark)) + | AffixRow( + "Switch", + boolState.ToSwitchInput().Label("Enable").Prefix(Icons.Bell), + boolState.ToSwitchInput().Label("Enable").Suffix(Icons.BadgeQuestionMark), + boolState.ToSwitchInput().Label("Enable").Prefix(Icons.Bell).Suffix(Icons.BadgeQuestionMark)) + | AffixRow( + "Toggle", + boolState.ToToggleInput(Icons.Star).Label("Enable").Prefix(Icons.Bell), + boolState.ToToggleInput(Icons.Star).Label("Enable").Suffix(Icons.BadgeQuestionMark), + boolState + .ToToggleInput(Icons.Star) + .Label("Enable") + .Prefix(Icons.Bell) + .Suffix(Icons.BadgeQuestionMark)) + | AffixRow( + "Nullable", + nullableState.ToBoolInput().Label("Enable").Nullable().Prefix(Icons.Bell), + nullableState.ToBoolInput().Label("Enable").Nullable().Suffix(Icons.BadgeQuestionMark), + nullableState + .ToBoolInput() + .Label("Enable") + .Nullable() + .Prefix(Icons.Bell) + .Suffix(Icons.BadgeQuestionMark)) + | AffixRow( + "Invalid", + boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell).Invalid("Must be enabled"), + boolState.ToBoolInput().Label("Enable").Suffix(Icons.BadgeQuestionMark).Invalid("Must be enabled"), + boolState + .ToBoolInput() + .Label("Enable") + .Prefix(Icons.Bell) + .Suffix(Icons.BadgeQuestionMark) + .Invalid("Must be enabled")) + | AffixRow( + "Nullable + invalid", + nullableState.ToBoolInput().Label("Enable").Nullable().Prefix(Icons.Bell).Invalid("Required"), + nullableState.ToBoolInput().Label("Enable").Nullable().Suffix(Icons.BadgeQuestionMark).Invalid("Required"), + nullableState + .ToBoolInput() + .Label("Enable") + .Nullable() + .Prefix(Icons.Bell) + .Suffix(Icons.BadgeQuestionMark) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both prefix and suffix at Small, Medium, and Large — compare row height and icon gaps to the Number/Select tabs.") + | DensityHeaderRow() + | BoolDensityRow( + "Checkbox", + boolState.ToBoolInput().Label("Enable").Prefix(Icons.Bell).Suffix(Icons.BadgeQuestionMark)) + | BoolDensityRow( + "Both + invalid", + boolState + .ToBoolInput() + .Label("Enable") + .Prefix(Icons.Bell) + .Suffix(Icons.BadgeQuestionMark) + .Invalid("Must be enabled")); + } +} + +public class InputAffixesColorView : ViewBase +{ + public override object Build() + { + var colorState = UseState("#6366f1"); + var nullableState = UseState(() => null); + + ColorInputBase TextAffix(ColorInputBase input) => input.Variant(ColorInputVariant.Text); + ColorInputBase PickerAffix(ColorInputBase input) => input; + + return Layout.Vertical() + | Callout.Info( + "Color affixes match text/bool: same affix shell, field padding, and invalid/clear in the suffix cluster when suffix is present.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Text", + TextAffix(colorState.ToColorInput()).Prefix(Icons.Palette), + TextAffix(colorState.ToColorInput()).Suffix(Icons.Pipette), + TextAffix(colorState.ToColorInput()).Prefix(Icons.Palette).Suffix(Icons.Pipette)) + | AffixRow( + "Text + picker", + PickerAffix(colorState.ToColorInput()).Prefix(Icons.Palette), + PickerAffix(colorState.ToColorInput()).Suffix(Icons.Pipette), + PickerAffix(colorState.ToColorInput()).Prefix(Icons.Palette).Suffix(Icons.Pipette)) + | AffixRow( + "Nullable", + TextAffix(nullableState.ToColorInput()).Nullable().Prefix(Icons.Palette), + TextAffix(nullableState.ToColorInput()).Nullable().Suffix(Icons.Pipette), + TextAffix(nullableState.ToColorInput()) + .Nullable() + .Prefix(Icons.Palette) + .Suffix(Icons.Pipette)) + | AffixRow( + "Invalid", + TextAffix(colorState.ToColorInput()).Prefix(Icons.Palette).Invalid("Invalid color"), + TextAffix(colorState.ToColorInput()).Suffix(Icons.Pipette).Invalid("Invalid color"), + TextAffix(colorState.ToColorInput()) + .Prefix(Icons.Palette) + .Suffix(Icons.Pipette) + .Invalid("Invalid color")) + | AffixRow( + "Nullable + invalid", + TextAffix(nullableState.ToColorInput()).Nullable().Prefix(Icons.Palette).Invalid("Required"), + TextAffix(nullableState.ToColorInput()).Nullable().Suffix(Icons.Pipette).Invalid("Required"), + TextAffix(nullableState.ToColorInput()) + .Nullable() + .Prefix(Icons.Palette) + .Suffix(Icons.Pipette) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Text + picker with both affixes at Small, Medium, and Large.") + | DensityHeaderRow() + | ColorDensityRow( + "Both", + PickerAffix(colorState.ToColorInput()).Prefix(Icons.Palette).Suffix(Icons.Pipette)) + | ColorDensityRow( + "Both + invalid", + PickerAffix(colorState.ToColorInput()) + .Prefix(Icons.Palette) + .Suffix(Icons.Pipette) + .Invalid("Invalid color")); + } +} + +public class InputAffixesFeedbackView : ViewBase +{ + public override object Build() + { + var starsState = UseState(3); + var emojisState = UseState(3); + var thumbsState = UseState(true); + var nullableThumbsState = UseState(() => null); + + return Layout.Vertical() + | Callout.Info( + "Feedback affixes match bool/color: shrink-to-fit shell, text field padding, invalid in suffix cluster when suffix is present.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Stars", + starsState.ToFeedbackInput().Stars().Prefix(Icons.Star), + starsState.ToFeedbackInput().Stars().Suffix(Icons.MessageSquare), + starsState.ToFeedbackInput().Stars().Prefix(Icons.Star).Suffix(Icons.MessageSquare)) + | AffixRow( + "Emojis", + emojisState.ToFeedbackInput().Emojis().Prefix(Icons.Smile), + emojisState.ToFeedbackInput().Emojis().Suffix(Icons.MessageSquare), + emojisState.ToFeedbackInput().Emojis().Prefix(Icons.Smile).Suffix(Icons.MessageSquare)) + | AffixRow( + "Thumbs", + thumbsState.ToFeedbackInput().Thumbs().Prefix(Icons.ThumbsUp), + thumbsState.ToFeedbackInput().Thumbs().Suffix(Icons.MessageSquare), + thumbsState.ToFeedbackInput().Thumbs().Prefix(Icons.ThumbsUp).Suffix(Icons.MessageSquare)) + | AffixRow( + "Invalid", + starsState.ToFeedbackInput().Stars().Prefix(Icons.Star).Invalid("Required"), + starsState.ToFeedbackInput().Stars().Suffix(Icons.MessageSquare).Invalid("Required"), + starsState + .ToFeedbackInput() + .Stars() + .Prefix(Icons.Star) + .Suffix(Icons.MessageSquare) + .Invalid("Required")) + | AffixRow( + "Nullable thumbs", + nullableThumbsState.ToFeedbackInput().Thumbs().Nullable().Prefix(Icons.ThumbsUp), + nullableThumbsState.ToFeedbackInput().Thumbs().Nullable().Suffix(Icons.MessageSquare), + nullableThumbsState + .ToFeedbackInput() + .Thumbs() + .Nullable() + .Prefix(Icons.ThumbsUp) + .Suffix(Icons.MessageSquare)) + | Text.H2("Densities") + | Callout.Info("Stars with both affixes at Small, Medium, and Large.") + | DensityHeaderRow() + | FeedbackDensityRow( + "Both", + starsState.ToFeedbackInput().Stars().Prefix(Icons.Star).Suffix(Icons.MessageSquare)) + | FeedbackDensityRow( + "Both + invalid", + starsState + .ToFeedbackInput() + .Stars() + .Prefix(Icons.Star) + .Suffix(Icons.MessageSquare) + .Invalid("Required")); + } +} + +public class InputAffixesIconView : ViewBase +{ + public override object Build() + { + var iconState = UseState(Icons.Heart); + var nullableState = UseState(() => null); + + return Layout.Vertical() + | Callout.Info( + "Icon affixes match feedback: same affix shell and content padding; prefix icon aligns with other shrink-to-fit inputs. Compare prefix column to Feedback tab.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Icon", + iconState.ToIconInput().Prefix(Icons.Search), + iconState.ToIconInput().Suffix(Icons.Sparkles), + iconState.ToIconInput().Prefix(Icons.Search).Suffix(Icons.Sparkles)) + | AffixRow( + "Nullable", + nullableState.ToIconInput().Nullable().Prefix(Icons.Search), + nullableState.ToIconInput().Nullable().Suffix(Icons.Sparkles), + nullableState + .ToIconInput() + .Nullable() + .Prefix(Icons.Search) + .Suffix(Icons.Sparkles)) + | AffixRow( + "Invalid", + iconState.ToIconInput().Prefix(Icons.Search).Invalid("Required"), + iconState.ToIconInput().Suffix(Icons.Sparkles).Invalid("Required"), + iconState + .ToIconInput() + .Prefix(Icons.Search) + .Suffix(Icons.Sparkles) + .Invalid("Required")) + | AffixRow( + "Nullable + invalid", + nullableState.ToIconInput().Nullable().Prefix(Icons.Search).Invalid("Required"), + nullableState.ToIconInput().Nullable().Suffix(Icons.Sparkles).Invalid("Required"), + nullableState + .ToIconInput() + .Nullable() + .Prefix(Icons.Search) + .Suffix(Icons.Sparkles) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both affixes at Small, Medium, and Large — compare prefix alignment to Feedback row above.") + | DensityHeaderRow() + | IconDensityRow( + "Both", + iconState.ToIconInput().Prefix(Icons.Search).Suffix(Icons.Sparkles)) + | IconDensityRow( + "Both + invalid", + iconState + .ToIconInput() + .Prefix(Icons.Search) + .Suffix(Icons.Sparkles) + .Invalid("Required")); + } +} + +public class InputAffixesCodeView : ViewBase +{ + public override object Build() + { + var codeState = UseState("console.log('ivy');"); + var nullableState = UseState(() => null); + var invalidState = UseState("console.log('ivy');"); + + return Layout.Vertical() + | Callout.Info( + "Code affixes: prefix/suffix icons share one row with the copy control; copy sits in a trailing affix column when no suffix slot. Compare prefix alignment and right inset to Icon/Feedback tabs.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Code", + CodeAffixDemo(codeState).Prefix(Icons.Code), + CodeAffixDemo(codeState).Suffix(Icons.Braces), + CodeAffixDemo(codeState).Prefix(Icons.Code).Suffix(Icons.Braces)) + | AffixRow( + "Nullable", + CodeAffixDemo(nullableState).Nullable().Prefix(Icons.Code), + CodeAffixDemo(nullableState).Nullable().Suffix(Icons.Braces), + CodeAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.Code) + .Suffix(Icons.Braces)) + | AffixRow( + "Invalid", + CodeAffixDemo(invalidState).Prefix(Icons.Code).Invalid("Invalid snippet"), + CodeAffixDemo(invalidState).Suffix(Icons.Braces).Invalid("Invalid snippet"), + CodeAffixDemo(invalidState) + .Prefix(Icons.Code) + .Suffix(Icons.Braces) + .Invalid("Invalid snippet")) + | AffixRow( + "Nullable + invalid", + CodeAffixDemo(nullableState).Nullable().Prefix(Icons.Code).Invalid("Required"), + CodeAffixDemo(nullableState).Nullable().Suffix(Icons.Braces).Invalid("Required"), + CodeAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.Code) + .Suffix(Icons.Braces) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both affixes at Small, Medium, and Large — compare affix row height, icon gaps, and copy placement.") + | DensityHeaderRow() + | CodeDensityRow( + "Both", + CodeAffixDemo(codeState).Prefix(Icons.Code).Suffix(Icons.Braces)) + | CodeDensityRow( + "Both + invalid", + CodeAffixDemo(invalidState) + .Prefix(Icons.Code) + .Suffix(Icons.Braces) + .Invalid("Invalid snippet")); + } +} + +public class InputAffixesDateTimeView : ViewBase +{ + public override object Build() + { + var dateState = UseState(DateTime.Now); + var nullableState = UseState(() => null); + var invalidState = UseState(DateTime.Now); + + return Layout.Vertical() + | Callout.Info( + "DateTime affixes: same shell as number/select — centered prefix/suffix row, clear/invalid in suffix cluster or trailing affix column when only prefix is set.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "DateTime", + DateAffixDemo(dateState).Prefix(Icons.Calendar), + DateAffixDemo(dateState).Suffix(Icons.Clock), + DateAffixDemo(dateState).Prefix(Icons.Calendar).Suffix(Icons.Clock)) + | AffixRow( + "Nullable", + DateAffixDemo(nullableState).Nullable().Prefix(Icons.Calendar), + DateAffixDemo(nullableState).Nullable().Suffix(Icons.Clock), + DateAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.Calendar) + .Suffix(Icons.Clock)) + | AffixRow( + "Invalid", + DateAffixDemo(invalidState).Prefix(Icons.Calendar).Invalid("Required"), + DateAffixDemo(invalidState).Suffix(Icons.Clock).Invalid("Required"), + DateAffixDemo(invalidState) + .Prefix(Icons.Calendar) + .Suffix(Icons.Clock) + .Invalid("Required")) + | AffixRow( + "Nullable + invalid", + DateAffixDemo(nullableState).Nullable().Prefix(Icons.Calendar).Invalid("Required"), + DateAffixDemo(nullableState).Nullable().Suffix(Icons.Clock).Invalid("Required"), + DateAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.Calendar) + .Suffix(Icons.Clock) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both affixes at Small, Medium, and Large — compare row height and trailing clear beside suffix glyph.") + | DensityHeaderRow() + | DateTimeDensityRow( + "Both", + DateAffixDemo(dateState).Prefix(Icons.Calendar).Suffix(Icons.Clock)) + | DateTimeDensityRow( + "Both + invalid", + DateAffixDemo(invalidState) + .Prefix(Icons.Calendar) + .Suffix(Icons.Clock) + .Invalid("Required")); + } +} + +public class InputAffixesDateRangeView : ViewBase +{ + public override object Build() + { + var rangeState = UseState( + (DateOnly.FromDateTime(DateTime.Today), DateOnly.FromDateTime(DateTime.Today.AddDays(7)))); + var nullableState = UseState<(DateOnly?, DateOnly?)>(() => (null, null)); + var invalidState = UseState( + (DateOnly.FromDateTime(DateTime.Today), DateOnly.FromDateTime(DateTime.Today.AddDays(7)))); + + return Layout.Vertical() + | Callout.Info( + "Date range affixes: prefix/suffix icons align with other inputs; nullable clear and invalid use the shared trailing cluster beside the suffix glyph.") + | Text.H2("Affix layouts") + | AffixHeaderRow() + | AffixRow( + "Date range", + DateRangeAffixDemo(rangeState).Prefix(Icons.CalendarRange), + DateRangeAffixDemo(rangeState).Suffix(Icons.CalendarDays), + DateRangeAffixDemo(rangeState).Prefix(Icons.CalendarRange).Suffix(Icons.CalendarDays)) + | AffixRow( + "Nullable", + DateRangeAffixDemo(nullableState).Nullable().Prefix(Icons.CalendarRange), + DateRangeAffixDemo(nullableState).Nullable().Suffix(Icons.CalendarDays), + DateRangeAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.CalendarRange) + .Suffix(Icons.CalendarDays)) + | AffixRow( + "Invalid", + DateRangeAffixDemo(invalidState).Prefix(Icons.CalendarRange).Invalid("Required"), + DateRangeAffixDemo(invalidState).Suffix(Icons.CalendarDays).Invalid("Required"), + DateRangeAffixDemo(invalidState) + .Prefix(Icons.CalendarRange) + .Suffix(Icons.CalendarDays) + .Invalid("Required")) + | AffixRow( + "Nullable + invalid", + DateRangeAffixDemo(nullableState).Nullable().Prefix(Icons.CalendarRange).Invalid("Required"), + DateRangeAffixDemo(nullableState).Nullable().Suffix(Icons.CalendarDays).Invalid("Required"), + DateRangeAffixDemo(nullableState) + .Nullable() + .Prefix(Icons.CalendarRange) + .Suffix(Icons.CalendarDays) + .Invalid("Required")) + | Text.H2("Densities") + | Callout.Info("Both affixes at Small, Medium, and Large.") + | DensityHeaderRow() + | DateRangeDensityRow( + "Both", + DateRangeAffixDemo(rangeState).Prefix(Icons.CalendarRange).Suffix(Icons.CalendarDays)) + | DateRangeDensityRow( + "Both + invalid", + DateRangeAffixDemo(invalidState) + .Prefix(Icons.CalendarRange) + .Suffix(Icons.CalendarDays) + .Invalid("Required")); + } +} + +public class InputAffixesDensitiesView : ViewBase +{ + public override object Build() + { + var textState = UseState("ivy.app"); + var passwordState = UseState("secret"); + var searchState = UseState("ivy.app"); + var emailState = UseState("user@ivy.app"); + var telState = UseState("5550100"); + var urlState = UseState("ivy.app"); + var textareaState = UseState("Notes"); + + return Layout.Vertical() + | Callout.Info("Both prefix and suffix affixes with trailing controls at Small, Medium, and Large density.") + | DensityHeaderRow() + | DensityRow("Text", textState.ToTextInput().Nullable().Prefix(Icons.Link).Suffix(Icons.Globe)) + | DensityRow("Password", passwordState.ToPasswordInput().Prefix(Icons.Lock).Suffix(Icons.Key)) + | DensityRow("Search", searchState.ToSearchInput().Prefix(Icons.ListFilterPlus).Suffix(Icons.Tag).Placeholder("Search...")) + | DensityRow("Email", emailState.ToEmailInput().Prefix(Icons.Mail).Suffix(Icons.AtSign)) + | DensityRow("Tel", telState.ToTelInput().Prefix(Icons.Phone).Suffix(Icons.Hash)) + | DensityRow("Url", urlState.ToUrlInput().Prefix(Icons.Link).Suffix(Icons.ExternalLink)) + | DensityRow("Textarea", textareaState.ToTextareaInput().Nullable().Prefix(Icons.FileText).Suffix(Icons.Type)); + } +} + +static class InputAffixesGalleryHelpers +{ + internal static CodeInputBase CodeAffixDemo(IAnyState state) => + state.ToCodeInput().Language(Languages.Javascript).Height(Size.Units(24)); + + internal static DateTimeInputBase DateAffixDemo(IAnyState state) => + state.ToDateTimeInput(); + + internal static DateRangeInputBase DateRangeAffixDemo(IAnyState state) => + state.ToDateRangeInput(); + + internal static GridView AffixHeaderRow() => + Layout.Grid().Columns(4) + | null! + | Text.Monospaced("Prefix only") + | Text.Monospaced("Suffix only") + | Text.Monospaced("Both"); + + internal static GridView AffixRow(string label, object prefixOnly, object suffixOnly, object both) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | prefixOnly + | suffixOnly + | both; + + internal static GridView DensityHeaderRow() => + Layout.Grid().Columns(4) + | null! + | Text.Monospaced("Small") + | Text.Monospaced("Medium") + | Text.Monospaced("Large"); + + internal static GridView DensityRow(string label, TextInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView NumberDensityRow(string label, NumberInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView SelectDensityRow(string label, SelectInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView BoolDensityRow(string label, BoolInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView ColorDensityRow(string label, ColorInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView FeedbackDensityRow(string label, FeedbackInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView IconDensityRow(string label, IconInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView CodeDensityRow(string label, CodeInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView DateTimeDensityRow(string label, DateTimeInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); + + internal static GridView DateRangeDensityRow(string label, DateRangeInputBase input) => + Layout.Grid().Columns(4) + | Text.Monospaced(label) + | input.Small() + | input + | input.Large(); +} diff --git a/src/Ivy.Samples.Shared/Apps/Widgets/Inputs/TextInputApp.cs b/src/Ivy.Samples.Shared/Apps/Widgets/Inputs/TextInputApp.cs index e0f6099c28..528d4c2208 100644 --- a/src/Ivy.Samples.Shared/Apps/Widgets/Inputs/TextInputApp.cs +++ b/src/Ivy.Samples.Shared/Apps/Widgets/Inputs/TextInputApp.cs @@ -274,11 +274,6 @@ public override object Build() | Text.Monospaced("Nullable with prefix/suffix") | nullableState.ToTextInput().Prefix("$").Placeholder("Amount") | nullableState.ToTextInput().Suffix("%").Placeholder("Percentage") - | nullableState.ToTextInput().Prefix("https://").Suffix(".com").Placeholder("domain") - - | Text.Monospaced("Nullable + Invalid + ShortcutKey") - | nullableState.ToTextInput().Prefix("@").Invalid("Required field").ShortcutKey("Ctrl+P") - | nullableState.ToTextInput().Suffix(Icons.Search).Invalid("Invalid input").ShortcutKey("Ctrl+F") - | nullableState.ToTextInput().Prefix(Icons.Mail).Suffix(".com").Invalid("Error").ShortcutKey("Ctrl+B"); + | nullableState.ToTextInput().Prefix("https://").Suffix(".com").Placeholder("domain"); } } diff --git a/src/frontend/src/components/InvalidIcon.tsx b/src/frontend/src/components/InvalidIcon.tsx index 76e28d3892..13dad26b05 100644 --- a/src/frontend/src/components/InvalidIcon.tsx +++ b/src/frontend/src/components/InvalidIcon.tsx @@ -2,18 +2,32 @@ import { InfoIcon } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { cn } from "@/lib/utils"; -export const InvalidIcon: React.FC<{ message: string; className?: string }> = ({ - message, - className, -}) => { +export const InvalidIcon: React.FC<{ + message: string; + className?: string; + iconClassName?: string; +}> = ({ message, className, iconClassName }) => { return ( - - - + + +
{message}
diff --git a/src/frontend/src/components/ui/input/bool-input-variant.ts b/src/frontend/src/components/ui/input/bool-input-variant.ts index 2becb84cd5..6bfbe996ef 100644 --- a/src/frontend/src/components/ui/input/bool-input-variant.ts +++ b/src/frontend/src/components/ui/input/bool-input-variant.ts @@ -1,5 +1,19 @@ import { cva } from "class-variance-authority"; +/** Gap between control and label — scales with density. */ +export const boolInputControlGapVariant = cva("", { + variants: { + density: { + Small: "gap-1.5", + Medium: "gap-2", + Large: "gap-2.5", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + // Row min-height variants - matches TextInput heights for consistent form field alignment export const boolInputRowMinHeightVariant = cva("", { variants: { diff --git a/src/frontend/src/components/ui/input/color-input-variant.ts b/src/frontend/src/components/ui/input/color-input-variant.ts index 34e2741bb1..7ac6f97a45 100644 --- a/src/frontend/src/components/ui/input/color-input-variant.ts +++ b/src/frontend/src/components/ui/input/color-input-variant.ts @@ -16,6 +16,20 @@ export const colorInputVariant = cva( }, ); +/** Affix row min-height — matches bool/text field heights. */ +export const colorInputRowMinHeightVariant = cva("", { + variants: { + density: { + Small: "min-h-7", + Medium: "min-h-9", + Large: "min-h-11", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + export const colorInputPickerVariant = cva("", { variants: { density: { diff --git a/src/frontend/src/components/ui/input/date-range-input-variant.ts b/src/frontend/src/components/ui/input/date-range-input-variant.ts index 2f6c7be43d..b382e11161 100644 --- a/src/frontend/src/components/ui/input/date-range-input-variant.ts +++ b/src/frontend/src/components/ui/input/date-range-input-variant.ts @@ -1,13 +1,13 @@ import { cva } from "class-variance-authority"; export const dateRangeInputVariant = cva( - "w-full justify-start text-left font-normal pr-20 cursor-pointer bg-transparent", + "w-full justify-start text-left font-normal cursor-pointer bg-transparent", { variants: { density: { - Small: "h-8 px-3", - Medium: "h-9 px-4 py-2", - Large: "h-10 px-5 py-2", + Small: "h-7 min-h-7 max-h-7 px-2 py-0 text-xs [&_svg]:!size-3", + Medium: "h-9 min-h-9 max-h-9 px-3 py-0 text-sm [&_svg]:!size-4", + Large: "h-11 min-h-11 max-h-11 px-4 py-0 text-base [&_svg]:!size-5", }, }, defaultVariants: { @@ -16,12 +16,12 @@ export const dateRangeInputVariant = cva( }, ); -export const dateRangeInputIconVariant = cva("", { +export const dateRangeInputIconVariant = cva("shrink-0", { variants: { density: { - Small: "size-3", - Medium: "size-4", - Large: "size-5", + Small: "!size-3", + Medium: "!size-4", + Large: "!size-5", }, }, defaultVariants: { diff --git a/src/frontend/src/components/ui/input/date-time-input-variant.ts b/src/frontend/src/components/ui/input/date-time-input-variant.ts index ad29f1dd30..c9013a502d 100644 --- a/src/frontend/src/components/ui/input/date-time-input-variant.ts +++ b/src/frontend/src/components/ui/input/date-time-input-variant.ts @@ -1,13 +1,13 @@ import { cva } from "class-variance-authority"; export const dateTimeInputVariant = cva( - "w-full justify-start text-left font-normal pr-20 cursor-pointer bg-transparent", + "w-full justify-start text-left font-normal cursor-pointer bg-transparent", { variants: { density: { - Small: "h-7 px-2 text-xs", - Medium: "h-9 px-3 py-2 text-sm", - Large: "h-11 px-4 py-2 text-base", + Small: "h-7 min-h-7 max-h-7 px-2 py-0 text-xs [&_svg]:!size-3", + Medium: "h-9 min-h-9 max-h-9 px-3 py-0 text-sm [&_svg]:!size-4", + Large: "h-11 min-h-11 max-h-11 px-4 py-0 text-base [&_svg]:!size-5", }, }, defaultVariants: { @@ -16,7 +16,7 @@ export const dateTimeInputVariant = cva( }, ); -export const dateTimeInputIconVariant = cva("", { +export const dateTimeInputIconVariant = cva("shrink-0", { variants: { density: { Small: "!size-3", diff --git a/src/frontend/src/components/ui/input/icon-input-variant.ts b/src/frontend/src/components/ui/input/icon-input-variant.ts index 5fc280acc9..744272ffd9 100644 --- a/src/frontend/src/components/ui/input/icon-input-variant.ts +++ b/src/frontend/src/components/ui/input/icon-input-variant.ts @@ -16,6 +16,23 @@ export const iconInputTriggerVariant = cva( }, ); +/** Trigger inside affix shell — heights match textInputSizeVariant (h-7 / h-9 / h-11). */ +export const iconInputAffixTriggerVariant = cva( + "justify-start font-normal min-w-0 max-w-none rounded-none border-0 bg-transparent px-0! py-0! shadow-none dark:border-transparent dark:bg-transparent", + { + variants: { + density: { + Small: "h-7 text-xs", + Medium: "h-9 text-sm", + Large: "h-11 text-base", + }, + }, + defaultVariants: { + density: "Medium", + }, + }, +); + export const iconInputIconVariant = cva("", { variants: { density: { diff --git a/src/frontend/src/components/ui/input/text-input-variant.ts b/src/frontend/src/components/ui/input/text-input-variant.ts index be68484226..9f7c9220dc 100644 --- a/src/frontend/src/components/ui/input/text-input-variant.ts +++ b/src/frontend/src/components/ui/input/text-input-variant.ts @@ -1,6 +1,17 @@ import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; +import { Densities } from "@/types/density"; + +export type InputDensityVariant = "Small" | "Medium" | "Large"; + +/** Normalize widget density for cva (handles enum, string, and responsive fallbacks). */ +export function normalizeInputDensity(density?: Densities | string | null): InputDensityVariant { + const value = density?.toString(); + if (value === Densities.Small || value === "Small") return "Small"; + if (value === Densities.Large || value === "Large") return "Large"; + return "Medium"; +} /** * Ivy.Button in affix: outer cell owns spacing (`px-3` or tighter for icon-only). @@ -8,34 +19,331 @@ import { cn } from "@/lib/utils"; * `size-7`/`size-9` target is larger than the glyph, which reads as extra padding. */ export const affixEmbeddedButtonClasses = - "[&_button]:!px-0 [&_button]:text-foreground [&_button]:shadow-none [&_button]:rounded [&_button]:hover:bg-accent [&_button]:cursor-pointer [&_button]:transition-colors [&_button.size-7]:!size-4 [&_button.size-9]:!size-6"; + "[&_button:not([data-invalid-icon])]:!px-0 [&_button:not([data-invalid-icon])]:shadow-none [&_button:not([data-invalid-icon])]:rounded [&_button:not([data-invalid-icon])]:hover:bg-accent [&_button:not([data-invalid-icon])]:cursor-pointer [&_button:not([data-invalid-icon])]:transition-colors [&_button:not([data-invalid-icon]).size-7]:!size-4 [&_button:not([data-invalid-icon]).size-9]:!size-6"; -/** Tighter affix cell padding when the slot only contains an icon-sized button. */ +/** Tighter affix cell padding when the slot only contains an icon-sized Ivy button (not trailing invalid). */ export const affixIconOnlyCellPaddingClasses = - "has-[button.size-7]:px-1.5 has-[button.size-9]:px-2"; + "has-[button.size-7:not([data-invalid-icon])]:px-1.5 has-[button.size-9:not([data-invalid-icon])]:px-2"; + +/** Center icon glyphs (non-button) in affix cells for even visual weight. */ +export const affixIconGlyphCellClasses = + "[&:not(:has(button))]:justify-center [&:not(:has(button))]:min-w-9"; + +/** + * Affix / suffix slot icons (Ivy.Icon) — match trailing invalid icon size per density. + * !important overrides Lucide width/height attributes (default 24px). + */ +export const textInputAffixIconGlyphSizeVariant = cva( + "[&_svg]:block [&_svg]:shrink-0 [&_svg]:leading-none", + { + variants: { + density: { + Small: "[&_svg]:!size-3", + Medium: "[&_svg]:!size-4", + Large: "[&_svg]:!size-5", + }, + }, + defaultVariants: { + density: "Medium", + }, + }, +); + +/** Shared transparent affix chrome — no muted background; padding scales with field density. */ +export const textInputAffixCellVariant = cva( + cn( + "flex shrink-0 items-center bg-transparent text-muted-foreground", + affixEmbeddedButtonClasses, + affixIconOnlyCellPaddingClasses, + affixIconGlyphCellClasses, + ), + { + variants: { + side: { + prefix: "rounded-tl-fields rounded-bl-fields", + suffix: "rounded-tr-fields rounded-br-fields", + }, + density: { + Small: "", + Medium: "", + Large: "", + }, + }, + compoundVariants: [ + { side: "prefix", density: "Small", class: "pl-2 pr-1 text-xs" }, + { side: "prefix", density: "Medium", class: "pl-3 pr-1.5 text-sm" }, + { side: "prefix", density: "Large", class: "pl-4 pr-2 text-base" }, + { side: "suffix", density: "Small", class: "pl-1 pr-2 text-xs" }, + { side: "suffix", density: "Medium", class: "pl-1.5 pr-3 text-sm" }, + { side: "suffix", density: "Large", class: "pl-2 pr-4 text-base" }, + ], + defaultVariants: { + side: "prefix", + density: "Medium", + }, + }, +); -/** Affix cells: muted box by default; ghost uses transparent chrome with tight padding toward the input. */ export function textInputAffixCellClasses( side: "prefix" | "suffix", - ghostWithAffixes: boolean, + density: Densities = Densities.Medium, ): string { + const d = normalizeInputDensity(density); return cn( - "flex items-center text-muted-foreground", + textInputAffixCellVariant({ side, density: d }), + textInputAffixIconGlyphSizeVariant({ density: d }), + ); +} + +/** Horizontal gap between trailing icons — scales with field density. */ +export const textInputTrailingClusterGapVariant = cva("", { + variants: { + density: { + Small: "gap-0.5", + Medium: "gap-1.5", + Large: "gap-2.5", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + +export function textInputTrailingClusterGapClasses(density: Densities = Densities.Medium): string { + return textInputTrailingClusterGapVariant({ density: normalizeInputDensity(density) }); +} + +/** Trailing icon hit target — scales with field height at each density. */ +export const textInputTrailingHitTargetVariant = cva( + "inline-flex shrink-0 items-center justify-center overflow-hidden leading-none", + { + variants: { + density: { + Small: "size-5", + Medium: "size-6", + Large: "size-7", + }, + }, + defaultVariants: { + density: "Medium", + }, + }, +); + +export function textInputTrailingShortcutWrapperClasses( + density: Densities = Densities.Medium, +): string { + return cn(textInputTrailingHitTargetVariant({ density }), "w-auto px-0"); +} + +/** Suffix glyph in a trailing cluster — same hit target as trailing controls. */ +export const textInputSuffixGlyphSlotVariant = cva( + "inline-flex shrink-0 items-center justify-center overflow-hidden leading-none", + { + variants: { + density: { + Small: "size-5", + Medium: "size-6", + Large: "size-7", + }, + }, + defaultVariants: { + density: "Medium", + }, + }, +); + +export function textInputSuffixGlyphSlotClasses(density: Densities = Densities.Medium): string { + const d = normalizeInputDensity(density); + return cn( + textInputSuffixGlyphSlotVariant({ density: d }), + textInputAffixIconGlyphSizeVariant({ density: d }), + ); +} + +/** Trailing icons + suffix glyph in one affix cell — single gap between all icons. */ +/** Symmetric padding when suffix holds icon + trailing (overrides asymmetric pl/pr). */ +export const textInputAffixSuffixWithTrailingPaddingVariant = cva("!px-1.5", { + variants: { + density: { + Small: "!px-1", + Medium: "!px-1.5", + Large: "!px-2", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + +/** Symmetric padding for suffix/prefix cells that only contain an icon glyph. */ +export const textInputAffixIconOnlyPaddingVariant = cva("!px-2", { + variants: { + density: { + Small: "!px-1.5", + Medium: "!px-2", + Large: "!px-2.5", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + +export function textInputSuffixWithTrailingClusterClasses( + density: Densities = Densities.Medium, +): string { + const d = normalizeInputDensity(density); + return cn( + "relative z-10 flex w-max max-w-full shrink-0 flex-nowrap items-center justify-start", + textInputTrailingClusterGapVariant({ density: d }), + textInputAffixSuffixWithTrailingPaddingVariant({ density: d }), + ); +} + +/** Suffix affix shell (invalid/clear + suffix glyph) — no asymmetric pl/pr. */ +export function textInputAffixSuffixTrailingShellClasses( + density: Densities = Densities.Medium, +): string { + const d = normalizeInputDensity(density); + return cn( + "flex shrink-0 items-center self-center rounded-tr-fields rounded-br-fields bg-transparent text-muted-foreground", affixEmbeddedButtonClasses, - ghostWithAffixes - ? side === "suffix" - ? "shrink-0 bg-transparent pl-0 pr-1.5" - : "shrink-0 bg-transparent pl-2 pr-0.5" - : cn( - "px-3 bg-muted", - affixIconOnlyCellPaddingClasses, - side === "prefix" - ? "rounded-tl-fields rounded-bl-fields" - : "rounded-tr-fields rounded-br-fields", - ), + textInputAffixIconGlyphSizeVariant({ density: d }), ); } +/** Suffix cell with invalid/clear + suffix glyph — shell + cluster helpers. */ +export function textInputAffixSuffixTrailingCellClasses( + density: Densities = Densities.Medium, +): string { + return cn( + textInputAffixSuffixTrailingShellClasses(density), + textInputSuffixWithTrailingClusterClasses(density), + ); +} + +/** Invalid icon in an affix trailing cluster — glyph-sized only (no size-7 hit box). */ +export function textInputAffixInvalidIconClasses(): string { + return cn( + "inline-flex shrink-0 grow-0 basis-auto items-center justify-center self-center", + "size-auto min-h-0 min-w-0 p-0 m-0 border-0 bg-transparent shadow-none rounded-none", + "cursor-pointer hover:bg-transparent hover:text-inherit", + "focus-visible:ring-1 focus-visible:ring-ring", + ); +} + +/** Textarea: trailing stack + suffix icon, same horizontal gap to suffix glyph. */ +export function textareaSuffixWithTrailingClusterClasses( + density: Densities = Densities.Medium, +): string { + return cn(textInputSuffixWithTrailingClusterClasses(density), "items-start self-start pt-2"); +} + +/** Standalone trailing cluster (no suffix affix content in the same cell). */ +export function textInputTrailingBesideSuffixClasses( + density: Densities = Densities.Medium, +): string { + return cn( + "relative z-10 flex shrink-0 items-center self-stretch text-muted-foreground", + textInputTrailingClusterGapVariant({ density }), + ); +} + +/** Multiline fields: trailing stack top-aligned when not merged into suffix cell. */ +export function textareaTrailingBesideSuffixClasses(density: Densities = Densities.Medium): string { + return cn(textInputTrailingBesideSuffixClasses(density), "flex-col items-center self-start pt-2"); +} + +/** Clear / shortcut / invalid / password-eye cluster overlaid inside the field. */ +export const textInputTrailingOverlayPositionVariant = cva("", { + variants: { + density: { + Small: "right-1.5", + Medium: "right-2", + Large: "right-2.5", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + +export function textInputTrailingOverlayClasses(density: Densities = Densities.Medium): string { + return cn( + "pointer-events-none absolute top-1/2 flex -translate-y-1/2 flex-row flex-nowrap items-center", + textInputTrailingOverlayPositionVariant({ density }), + textInputTrailingClusterGapVariant({ density }), + ); +} + +/** Multiline overlay controls (top-right). */ +export const textareaTrailingOverlayPositionVariant = cva( + "pointer-events-none absolute z-10 flex flex-col items-center", + { + variants: { + density: { + Small: "right-2 top-1.5", + Medium: "right-2.5 top-2", + Large: "right-3 top-2.5", + }, + }, + defaultVariants: { + density: "Medium", + }, + }, +); + +export function textareaTrailingOverlayClasses(density: Densities = Densities.Medium): string { + return cn( + textareaTrailingOverlayPositionVariant({ density }), + textInputTrailingClusterGapVariant({ density }), + ); +} + +/** Vertical trailing stack beside textarea suffix. */ +export function textareaTrailingStackClasses(density: Densities = Densities.Medium): string { + return cn("flex flex-col items-center", textInputTrailingClusterGapVariant({ density })); +} + +/** Uniform trailing icon button — fixed hit target keeps eye/clear aligned beside suffix. */ +export function textInputTrailingIconButtonClasses( + overlay = false, + density: Densities = Densities.Medium, +): string { + const d = normalizeInputDensity(density); + return cn( + textInputTrailingHitTargetVariant({ density: d }), + "cursor-pointer rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none", + overlay && "pointer-events-auto", + ); +} + +/** Invalid icon slot — same footprint as eye/clear so cluster gaps stay even. */ +export function textInputTrailingInvalidSlotClasses( + overlay = false, + density: Densities = Densities.Medium, +): string { + return cn( + textInputTrailingIconButtonClasses(overlay, density), + "shrink-0 grow-0 basis-auto hover:bg-transparent hover:text-inherit", + ); +} + +/** Icon glyph size inside trailing buttons (no absolute top offsets). */ +export const textInputTrailingIconSizeVariant = cva("block shrink-0 leading-none", { + variants: { + density: { + Small: "size-3", + Medium: "size-4", + Large: "size-5", + }, + }, + defaultVariants: { + density: "Medium", + }, +}); + // Size variants for TextInputWidget export const textInputSizeVariant = cva("w-full", { variants: { @@ -91,17 +399,3 @@ export const xIconVariant = cva("text-muted-foreground hover:text-foreground", { density: "Medium", }, }); - -// Size variants for eye icons (password toggle) -export const eyeIconVariant = cva("", { - variants: { - density: { - Small: "size-3", - Medium: "size-4", - Large: "size-5", - }, - }, - defaultVariants: { - density: "Medium", - }, -}); diff --git a/src/frontend/src/components/ui/textarea/textarea.css b/src/frontend/src/components/ui/textarea/textarea.css index 5dfb70de04..d5f594eadd 100644 --- a/src/frontend/src/components/ui/textarea/textarea.css +++ b/src/frontend/src/components/ui/textarea/textarea.css @@ -31,4 +31,43 @@ .ivy-textarea-wrapper:has(.resize-none)::after { display: none; } + + /* Affixed textarea: grip belongs on the full input shell, not the field column. */ + .ivy-textarea-affixed-shell { + position: relative; + } + .ivy-textarea-affixed-shell .ivy-textarea-wrapper::before, + .ivy-textarea-affixed-shell .ivy-textarea-wrapper::after { + display: none; + } + .ivy-textarea-affixed-shell::before, + .ivy-textarea-affixed-shell::after { + content: ""; + position: absolute; + bottom: 4px; + right: 2px; + width: 2px; + height: 1px; + background: var(--muted-foreground); + transform: rotate(-45deg); + transform-origin: bottom right; + pointer-events: none; + z-index: 10; + } + .ivy-textarea-affixed-shell::after { + width: 6px; + bottom: 7px; + right: 2px; + } + + .ivy-textarea-affixed-shell .ivy-textarea-wrapper { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + .ivy-textarea-affixed-shell .ivy-textarea { + flex: 1; + min-height: 0; + } } diff --git a/src/frontend/src/components/ui/tooltip.tsx b/src/frontend/src/components/ui/tooltip.tsx index e71d637c51..f7a96706c3 100644 --- a/src/frontend/src/components/ui/tooltip.tsx +++ b/src/frontend/src/components/ui/tooltip.tsx @@ -22,12 +22,17 @@ const TooltipContext = React.createContext(null); * never see it on iPad/iPhone. Wrap Root with a small controlled-state shim and * let the Trigger toggle it on touch / pen pointerdown without affecting mouse. */ +type TooltipProps = React.ComponentPropsWithoutRef & + Pick, "className" | "style">; + const Tooltip = ({ open: openProp, defaultOpen, onOpenChange, + className, + style, ...props -}: React.ComponentPropsWithoutRef) => { +}: TooltipProps) => { const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; @@ -44,7 +49,12 @@ const Tooltip = ({ return ( - + ); }; diff --git a/src/frontend/src/widgets/inputs/BoolInputWidget.tsx b/src/frontend/src/widgets/inputs/BoolInputWidget.tsx index 2591981ded..9274a29978 100644 --- a/src/frontend/src/widgets/inputs/BoolInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/BoolInputWidget.tsx @@ -14,9 +14,20 @@ import { Densities } from "@/types/density"; import { labelSizeVariant, descriptionSizeVariant, + boolInputControlGapVariant, boolInputRowMinHeightVariant, } from "@/components/ui/input/bool-input-variant"; import { EMPTY_ARRAY } from "@/lib/constants"; +import { InvalidIcon } from "@/components/InvalidIcon"; +import { + normalizeInputDensity, + textInputAffixCellClasses, + textInputAffixInvalidIconClasses, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputSizeVariant, + textInputTrailingIconSizeVariant, +} from "@/components/ui/input/text-input-variant"; type VariantType = "Checkbox" | "Switch" | "Toggle"; @@ -48,6 +59,7 @@ interface BaseVariantProps { disabled: boolean; loading: boolean; density?: Densities; + inAffixShell?: boolean; autoFocus?: boolean; "data-testid"?: string; } @@ -75,15 +87,16 @@ const InputLabel: React.FC<{ density?: Densities; }> = React.memo(({ id, label, description, density = Densities.Medium }) => { if (!label && !description) return null; + const d = normalizeInputDensity(density); return (
{label && ( -
); }); @@ -122,10 +135,12 @@ const VariantComponents = { nullable, invalid, density = Densities.Medium, + inAffixShell = false, autoFocus, onCheckedChange, "data-testid": dataTestId, }: CheckboxVariantProps) => { + const d = normalizeInputDensity(density); const checkboxElement = (
- {loading && } + {loading && }
); - const content = ( + return (
e.stopPropagation()} @@ -158,11 +175,9 @@ const VariantComponents = { > {checkboxElement} - +
); - - return content; }, ), @@ -176,11 +191,13 @@ const VariantComponents = { loading, invalid, density = Densities.Medium, + inAffixShell = false, autoFocus, icon, onCheckedChange, "data-testid": dataTestId, }: SwitchVariantProps) => { + const d = normalizeInputDensity(density); const switchElement = (
- {loading && } + {loading && }
); - const content = ( + return (
e.stopPropagation()} @@ -213,11 +232,9 @@ const VariantComponents = { > {switchElement} - +
); - - return content; }, ), @@ -232,10 +249,12 @@ const VariantComponents = { icon, invalid, density = Densities.Medium, + inAffixShell = false, autoFocus, onPressedChange, "data-testid": dataTestId, }: ToggleVariantProps) => { + const d = normalizeInputDensity(density); const toggleElement = (
{icon && } - {loading && } + {loading && }
); - const content = ( + return (
e.stopPropagation()} @@ -270,11 +291,9 @@ const VariantComponents = { > {toggleElement} - +
); - - return content; }, ), }; @@ -298,7 +317,6 @@ export const BoolInputWidget: React.FC = ({ }) => { const eventHandler = useEventHandler(); - // Normalize undefined to null when nullable const normalizedValue = nullable && value === undefined ? null : value; const [localValue, setLocalValue] = useOptimisticValue(normalizedValue, false); @@ -313,12 +331,16 @@ export const BoolInputWidget: React.FC = ({ ); const VariantComponent = useMemo(() => VariantComponents[variant], [variant]); + const densityKey = normalizeInputDensity(density); const prefixContent = slots?.Prefix; const suffixContent = slots?.Suffix; const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showTrailing = Boolean(invalid); + const controlInvalid = trailingBesideSuffix && showTrailing ? undefined : invalid; const variantContent = ( = ({ loading={loading} nullable={nullable} icon={icon} - invalid={invalid} + invalid={controlInvalid} density={density} + inAffixShell={hasAffixes} autoFocus={autoFocus} onCheckedChange={handleChange} onPressedChange={handleChange} @@ -355,20 +378,49 @@ export const BoolInputWidget: React.FC = ({ {hasAffixes ? (
{hasPrefix && ( -
- {prefixContent} -
+
{prefixContent}
)} -
{variantContent}
+
+ {variantContent} +
{hasSuffix && ( -
- {suffixContent} +
+ {trailingBesideSuffix && showTrailing && ( + <> + {invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/ColorInputWidget.tsx b/src/frontend/src/widgets/inputs/ColorInputWidget.tsx index 569764dd2a..0543fe977b 100644 --- a/src/frontend/src/widgets/inputs/ColorInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/ColorInputWidget.tsx @@ -14,11 +14,23 @@ import { combineHexAlpha, } from "./color-utils"; import { + colorInputRowMinHeightVariant, colorInputVariant, colorInputPickerVariant, } from "@/components/ui/input/color-input-variant"; import { Densities } from "@/types/density"; -import { xIconVariant } from "@/components/ui/input/text-input-variant"; +import { + normalizeInputDensity, + textInputAffixCellClasses, + textInputAffixInvalidIconClasses, + textInputSizeVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputTrailingOverlayClasses, +} from "@/components/ui/input/text-input-variant"; import { EMPTY_ARRAY } from "@/lib/constants"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -184,6 +196,109 @@ interface CustomColorPickerProps { onFocus?: (e: React.FocusEvent) => void; } +interface ColorInputAffixLayoutProps { + density: Densities; + invalid?: string; + ghost?: boolean; + disabled?: boolean; + nullable: boolean; + hasValue: boolean; + prefixContent?: React.ReactNode[]; + suffixContent?: React.ReactNode[]; + onClear: () => void; + children: (ctx: { + trailingBesideSuffix: boolean; + showTrailing: boolean; + showClear: boolean; + fieldInvalid: string | undefined; + }) => React.ReactNode; +} + +const ColorInputAffixLayout: React.FC = ({ + density, + invalid, + ghost, + disabled, + nullable, + hasValue, + prefixContent, + suffixContent, + onClear, + children, +}) => { + const densityKey = normalizeInputDensity(density); + const hasPrefix = (prefixContent?.length ?? 0) > 0; + const hasSuffix = (suffixContent?.length ?? 0) > 0; + const trailingBesideSuffix = hasSuffix; + const showClear = nullable && hasValue && !disabled; + const showTrailing = showClear || Boolean(invalid); + const fieldInvalid = trailingBesideSuffix && invalid ? undefined : invalid; + + return ( +
+ {hasPrefix && ( +
{prefixContent}
+ )} +
+ {children({ trailingBesideSuffix, showTrailing, showClear, fieldInvalid })} +
+ {hasSuffix && ( +
+ {trailingBesideSuffix && showTrailing && ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )} +
+ )} +
+ ); +}; + const CustomColorPicker: React.FC = ({ density, disabled, @@ -310,84 +425,112 @@ export const ColorInputWidget: React.FC = ({ fireColorChange(null); }; - // --- Variant rendering logic --- - if (variant === "Text") { - const textContent = ( -
- ( +
+ - {(invalid || (nullable && localValue !== null && !disabled)) && ( -
- {invalid && ( - - - - )} - {nullable && localValue !== null && !disabled && ( - - )} -
+ "border-transparent shadow-none bg-transparent dark:border-transparent dark:bg-transparent", + ctx.fieldInvalid && inputStyles.invalidInput, + inAffixShell && hasPrefix && "rounded-l-none", + inAffixShell && hasSuffix && "rounded-r-none", + ctx.trailingBesideSuffix && ctx.showTrailing && "pr-2", + !ctx.trailingBesideSuffix && ctx.showTrailing && "pr-8", + !ctx.trailingBesideSuffix && ctx.showClear && invalid && "pr-16", )} -
+ /> + {!ctx.trailingBesideSuffix && ctx.showTrailing && ( +
+ {ctx.showClear && ( + + )} + {invalid && ( + + )} +
+ )} +
+ ); + + const wrapWithAffixes = ( + field: (ctx: { + trailingBesideSuffix: boolean; + showTrailing: boolean; + showClear: boolean; + fieldInvalid: string | undefined; + }) => React.ReactNode, + ) => { + if (!hasAffixes) { + const showClearStandalone = nullable && hasValue && !disabled; + return field({ + trailingBesideSuffix: false, + showTrailing: showClearStandalone || Boolean(invalid), + showClear: showClearStandalone, + fieldInvalid: invalid, + }); + } + return ( + + {(ctx) => field(ctx)} + ); + }; + // --- Variant rendering logic --- + if (variant === "Text") { return (
- {hasAffixes ? ( -
- {hasPrefix && ( -
- {prefixContent} -
- )} - {textContent} - {hasSuffix && ( -
- {suffixContent} -
- )} -
- ) : ( - textContent - )} + {wrapWithAffixes((ctx) => renderColorTextField(ctx, hasAffixes))} {allowAlpha && ( = ({ } // Default: TextAndPicker - const defaultContent = ( - <> - -
- - {(invalid || (nullable && localValue !== null && !disabled)) && ( -
- {/* Invalid icon - rightmost */} - {invalid && } - {nullable && localValue !== null && !disabled && ( - - )} -
- )} -
- - ); - return (
- {hasAffixes ? ( -
- {hasPrefix && ( -
- {prefixContent} -
- )} -
{defaultContent}
- {hasSuffix && ( -
- {suffixContent} -
- )} -
- ) : ( - <>{defaultContent} - )} + {wrapWithAffixes((ctx) => ( + <> + + {renderColorTextField(ctx, hasAffixes)} + + ))} {allowAlpha && ( = ({ ); const handleClear = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); if (!events.includes("OnChange")) return; if (disabled) return; const cleared = { item1: null, item2: null }; @@ -194,13 +195,27 @@ export const DateRangeInputWidget: React.FC = ({ const displayFormat = formatProp || "LLL dd, y"; // Show clear button if nullable, not disabled, and has a value - const showClear = nullable && !disabled && (date?.from || date?.to); + const showClear = nullable && !disabled && Boolean(date?.from ?? date?.to); const prefixContent = slots?.Prefix; const suffixContent = slots?.Suffix; const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showTrailing = showClear || Boolean(invalid); + const controlInvalid = dateInputControlInvalid( + hasAffixes, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + hasAffixes, + trailingBesideSuffix, + showClear, + invalid, + ); const triggerContent = ( @@ -213,10 +228,11 @@ export const DateRangeInputWidget: React.FC = ({ data-slot="calendar" className={cn( dateRangeInputVariant({ density }), + "inline-flex items-center", "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && "border-destructive focus-visible:ring-destructive", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + controlInvalid && "border-destructive focus-visible:ring-destructive", + trailingPadding, hasAffixes && "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", hasPrefix && "rounded-l-none", hasSuffix && "rounded-r-none", @@ -318,68 +334,38 @@ export const DateRangeInputWidget: React.FC = ({ ); + if (!hasAffixes) { + return ( +
+ {triggerContent} + {showTrailing && ( + + )} +
+ ); + } + return (
- {hasAffixes ? ( -
- {hasPrefix && ( -
- {prefixContent} -
- )} -
{triggerContent}
- {hasSuffix && ( -
- {suffixContent} -
- )} -
- ) : ( - triggerContent - )} - {/* Icons absolutely positioned */} - {(showClear || invalid) && ( -
- {showClear && ( - - )} - {invalid && } -
- )} + +
{triggerContent}
+
); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeInputWidget.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeInputWidget.tsx index c885ba0022..3c6fb7d257 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeInputWidget.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useEventHandler } from "@/components/event-handler"; import { useOptimisticValue } from "../shared/useOptimisticValue"; import { Densities } from "@/types/density"; @@ -19,10 +19,7 @@ import { WeekVariant } from "./WeekVariant"; import { YearVariant } from "./YearVariant"; import { EMPTY_ARRAY } from "@/lib/constants"; import { cn } from "@/lib/utils"; -import { - affixEmbeddedButtonClasses, - affixIconOnlyCellPaddingClasses, -} from "@/components/ui/input/text-input-variant"; +import { DateInputAffixShell } from "./affix"; const VariantComponents: Record< VariantType, @@ -73,8 +70,8 @@ export const DateTimeInputWidget: React.FC = ({ }) => { const eventHandler = useEventHandler(); const firstDayOfWeek = resolveDayOfWeek(firstDayOfWeekRaw); + const [affixFocused, setAffixFocused] = useState(false); - // Normalize undefined to null when nullable const normalizedValue = nullable && value === undefined ? undefined : value; const [localValue, setLocalValue] = useOptimisticValue(normalizedValue, false); @@ -93,12 +90,10 @@ export const DateTimeInputWidget: React.FC = ({ (time: string) => { if (disabled) return; - // For Time variant, send the time string directly if (variant === "Time") { setLocalValue(time); if (events.includes("OnChange")) eventHandler("OnChange", id, [time]); } else { - // DateTime variant: merge time into current date so we don't overwrite with today if (!time?.trim()) return; const parts = time.split(":").map(Number); const [hours, minutes, seconds] = [parts[0] || 0, parts[1] || 0, parts[2] || 0]; @@ -123,6 +118,7 @@ export const DateTimeInputWidget: React.FC = ({ const handleFocusChange = useCallback( (focused: boolean) => { if (disabled) return; + setAffixFocused(focused); if (focused) { if (events.includes("OnFocus")) eventHandler("OnFocus", id, []); } else { @@ -137,6 +133,26 @@ export const DateTimeInputWidget: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showClear = nullable && !disabled && localValue != null && localValue !== ""; + const showTrailing = showClear || Boolean(invalid); + const trailingInAffixCell = hasAffixes && !trailingBesideSuffix && showTrailing; + + const handleAffixClear = useCallback( + (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (disabled) return; + if (variant === "Time") { + const clearedValue = nullable ? undefined : ""; + setLocalValue(clearedValue); + if (events.includes("OnChange")) eventHandler("OnChange", id, [nullable ? null : ""]); + } else { + handleDateChange(undefined); + } + }, + [disabled, variant, nullable, setLocalValue, events, eventHandler, id, handleDateChange], + ); const variantElement = ( = ({ onTimeChange={handleTimeChange} onFocusChange={handleFocusChange} data-testid={dataTestId} + inAffixShell={hasAffixes} + trailingBesideSuffix={trailingBesideSuffix} /> ); @@ -165,44 +183,28 @@ export const DateTimeInputWidget: React.FC = ({ } return ( -
- {hasPrefix && ( -
- {prefixContent} -
- )}
{variantElement}
- {hasSuffix && ( -
- {suffixContent} -
- )} -
+ ); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeVariant.tsx index bc77217e39..1df6819e2a 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateTimeVariant.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { DateTimeVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; import { useTimeConstraints } from "./useTimeConstraints"; export const DateTimeVariant: React.FC = ({ @@ -35,6 +36,8 @@ export const DateTimeVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const [open, setOpen] = useState(false); @@ -52,6 +55,18 @@ export const DateTimeVariant: React.FC = ({ const minDate = useMemo(() => (min ? new Date(min) : undefined), [min]); const maxDate = useMemo(() => (max ? new Date(max) : undefined), [max]); const showClear = nullable && !disabled && value != null && value !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const disabledDays = useMemo(() => { const matchers: Array<{ before: Date } | { after: Date }> = []; @@ -185,9 +200,11 @@ export const DateTimeVariant: React.FC = ({ dateTimeInputVariant({ density }), "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, + inAffixShell && + "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", )} data-testid={dataTestId} onFocus={() => { @@ -241,7 +258,7 @@ export const DateTimeVariant: React.FC = ({ className={cn( "bg-transparent appearance-none [&::-webkit-calendar-picker-indicator]:hidden", dateTimeInputTextVariant({ density }), - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, )} data-testid={dataTestId ? `${dataTestId}-time` : undefined} /> @@ -249,12 +266,14 @@ export const DateTimeVariant: React.FC = ({
- + {!inAffixShell && ( + + )}
); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateVariant.tsx index fb9d23c73d..e88df9acc5 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/DateVariant.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { DateVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; export const DateVariant: React.FC = ({ value, @@ -31,6 +32,8 @@ export const DateVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const [open, setOpen] = useState(false); @@ -46,6 +49,18 @@ export const DateVariant: React.FC = ({ const date = value ? new Date(value) : undefined; const showClear = nullable && !disabled && value != null && value !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const disabledDays = useMemo(() => { const matchers: Array<{ before: Date } | { after: Date }> = []; @@ -89,9 +104,11 @@ export const DateVariant: React.FC = ({ dateTimeInputVariant({ density }), "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, + inAffixShell && + "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", )} data-testid={dataTestId} onFocus={() => { @@ -125,12 +142,14 @@ export const DateVariant: React.FC = ({ /> - + {!inAffixShell && ( + + )}
); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/MonthVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/MonthVariant.tsx index 4af423a9da..0abd2cbe25 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/MonthVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/MonthVariant.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { MonthVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -31,6 +32,8 @@ export const MonthVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const [open, setOpen] = useState(false); @@ -69,6 +72,18 @@ export const MonthVariant: React.FC = ({ } const showClear = nullable && !disabled && value != null && value !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const isMonthDisabled = (monthIndex: number) => { if (minDate && new Date(viewYear, monthIndex + 1, 0) < minDate) return true; @@ -111,9 +126,11 @@ export const MonthVariant: React.FC = ({ dateTimeInputVariant({ density }), "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, + inAffixShell && + "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", )} data-testid={dataTestId} onFocus={() => { @@ -184,12 +201,14 @@ export const MonthVariant: React.FC = ({ - + {!inAffixShell && ( + + )} ); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/TimeVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/TimeVariant.tsx index 293ac6e7cd..ff13d75d79 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/TimeVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/TimeVariant.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { TimeVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; import { useTimeConstraints } from "./useTimeConstraints"; export const TimeVariant: React.FC = ({ @@ -28,6 +29,8 @@ export const TimeVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const inputRef = React.useRef(null); const hasAutoFocusedRef = React.useRef(false); @@ -67,6 +70,18 @@ export const TimeVariant: React.FC = ({ const { timeStepSeconds, timeMin, timeMax, getSnappedTime } = useTimeConstraints(min, max, step); const showClear = nullable && !disabled && propValue != null && propValue !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const handleClear = (e?: React.MouseEvent) => { e?.preventDefault(); @@ -107,7 +122,8 @@ export const TimeVariant: React.FC = ({
= ({ className={cn( "bg-transparent appearance-none [&::-webkit-calendar-picker-indicator]:hidden cursor-pointer w-full border-0 shadow-none focus-visible:ring-0", dateTimeInputTextVariant({ density }), - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed opacity-50 text-muted-foreground", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, )} />
- + {!inAffixShell && ( + + )} ); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/WeekVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/WeekVariant.tsx index a18f581868..f5ffcb5282 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/WeekVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/WeekVariant.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { WeekVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; function formatWeekDisplay(date: Date, formatProp?: string): string { if (formatProp) return format(date, formatProp); @@ -37,6 +38,8 @@ export const WeekVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const [open, setOpen] = useState(false); @@ -72,6 +75,18 @@ export const WeekVariant: React.FC = ({ }, [minDate, maxDate]); const showClear = nullable && !disabled && value != null && value !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const handleClear = (e?: React.MouseEvent) => { e?.preventDefault(); @@ -123,9 +138,11 @@ export const WeekVariant: React.FC = ({ dateTimeInputVariant({ density }), "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, + inAffixShell && + "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", )} data-testid={dataTestId} onFocus={() => { @@ -163,12 +180,14 @@ export const WeekVariant: React.FC = ({ /> - + {!inAffixShell && ( + + )} ); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/YearVariant.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/YearVariant.tsx index dacd605e8d..20d44990c2 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/YearVariant.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/YearVariant.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/input/date-time-input-variant"; import { YearVariantProps } from "./types"; import { ClearAndInvalidIcons } from "./shared"; +import { dateInputControlInvalid, dateInputTriggerTrailingPadding } from "./affix"; function getDecadeStart(year: number): number { return Math.floor(year / 10) * 10; @@ -33,6 +34,8 @@ export const YearVariant: React.FC = ({ autoFocus, "data-testid": dataTestId, onFocusChange, + inAffixShell, + trailingBesideSuffix, }) => { const [open, setOpen] = useState(false); @@ -71,6 +74,18 @@ export const YearVariant: React.FC = ({ } const showClear = nullable && !disabled && value != null && value !== ""; + const controlInvalid = dateInputControlInvalid( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); + const trailingPadding = dateInputTriggerTrailingPadding( + inAffixShell, + trailingBesideSuffix, + showClear, + invalid, + ); const handleClear = (e?: React.MouseEvent) => { e?.preventDefault(); @@ -116,9 +131,11 @@ export const YearVariant: React.FC = ({ dateTimeInputVariant({ density }), "dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10", !date && "text-muted-foreground", - invalid && inputStyles.invalidInput, + controlInvalid && inputStyles.invalidInput, disabled && "cursor-not-allowed", - showClear && invalid ? "pr-16" : showClear || invalid ? "pr-8" : "", + trailingPadding, + inAffixShell && + "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0", )} data-testid={dataTestId} onFocus={() => { @@ -195,12 +212,14 @@ export const YearVariant: React.FC = ({ - + {!inAffixShell && ( + + )} ); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/affix.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/affix.tsx new file mode 100644 index 0000000000..248d094736 --- /dev/null +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/affix.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { InvalidIcon } from "@/components/InvalidIcon"; +import { Densities } from "@/types/density"; +import { boolInputRowMinHeightVariant } from "@/components/ui/input/bool-input-variant"; +import { + normalizeInputDensity, + textInputAffixCellClasses, + textInputAffixIconOnlyPaddingVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, +} from "@/components/ui/input/text-input-variant"; + +/** Affix strip aligned with number/select/icon inputs. */ +export function dateInputAffixCellClasses( + side: "prefix" | "suffix", + density: Densities, + densityKey: ReturnType, + options: { withTrailingCluster: boolean; iconOnlyPadding: boolean }, +): string { + return cn( + textInputAffixCellClasses(side, density), + "relative z-10 shrink-0 overflow-visible", + options.withTrailingCluster && textInputSuffixWithTrailingClusterClasses(density), + options.iconOnlyPadding && textInputAffixIconOnlyPaddingVariant({ density: densityKey }), + ); +} + +/** Invalid on inner trigger only when trailing is not in the suffix cluster. */ +export function dateInputControlInvalid( + inAffixShell?: boolean, + trailingBesideSuffix?: boolean, + showClear = false, + invalid?: string, +): string | undefined { + if (inAffixShell && trailingBesideSuffix && (showClear || invalid)) { + return undefined; + } + return invalid; +} + +/** Reserve space for clear/invalid overlay on the trigger when not in affix shell. */ +export function dateInputTriggerTrailingPadding( + inAffixShell?: boolean, + trailingBesideSuffix?: boolean, + showClear = false, + invalid?: string, +): string { + if (inAffixShell) { + if (trailingBesideSuffix && (showClear || invalid)) return "pr-2"; + return ""; + } + if (showClear && invalid) return "pr-16"; + if (showClear || invalid) return "pr-8"; + return ""; +} + +export interface DateInputAffixLayoutProps { + inAffixShell?: boolean; + trailingBesideSuffix?: boolean; +} + +interface DateInputAffixShellProps { + density: Densities; + invalid?: string; + disabled?: boolean; + focused?: boolean; + hasPrefix: boolean; + hasSuffix: boolean; + prefixContent?: React.ReactNode; + suffixContent?: React.ReactNode; + showClear: boolean; + onClear: (e?: React.MouseEvent) => void; + children: React.ReactNode; +} + +export function DateInputAffixShell({ + density, + invalid, + disabled, + focused, + hasPrefix, + hasSuffix, + prefixContent, + suffixContent, + showClear, + onClear, + children, +}: DateInputAffixShellProps) { + const densityKey = normalizeInputDensity(density); + const showTrailing = showClear || Boolean(invalid); + const trailingBesideSuffix = hasSuffix; + const trailingInAffixCell = !trailingBesideSuffix && showTrailing; + const trailingControlCount = [showClear, Boolean(invalid)].filter(Boolean).length; + + const trailingCluster = () => ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + ); + + return ( +
+ {hasPrefix && ( +
+ {prefixContent} +
+ )} +
+ {children} +
+ {hasSuffix && ( +
+ {trailingBesideSuffix && showTrailing && trailingCluster()} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )} +
+ )} + {trailingInAffixCell && ( +
1, + iconOnlyPadding: trailingControlCount === 1, + })} + > + {trailingCluster()} +
+ )} +
+ ); +} diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/shared.tsx b/src/frontend/src/widgets/inputs/DateTimeInputWidget/shared.tsx index 3cc28394aa..f85331490d 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/shared.tsx +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/shared.tsx @@ -1,9 +1,14 @@ import * as React from "react"; import { X } from "lucide-react"; -import { cn } from "@/lib/utils"; import { InvalidIcon } from "@/components/InvalidIcon"; -import { dateTimeInputIconVariant } from "@/components/ui/input/date-time-input-variant"; import { Densities } from "@/types/density"; +import { + normalizeInputDensity, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputTrailingOverlayClasses, +} from "@/components/ui/input/text-input-variant"; interface ClearAndInvalidIconsProps { showClear?: boolean; @@ -12,6 +17,7 @@ interface ClearAndInvalidIconsProps { onClear: (e?: React.MouseEvent) => void; } +/** Standalone date/time field trailing cluster (no affix shell). */ export const ClearAndInvalidIcons: React.FC = ({ showClear = false, invalid, @@ -22,26 +28,28 @@ export const ClearAndInvalidIcons: React.FC = ({ return null; } + const densityKey = normalizeInputDensity(density); + return ( -
+
{showClear && ( )} - {/* Invalid icon - rightmost */} - {invalid && } + {invalid && ( + + )}
); }; diff --git a/src/frontend/src/widgets/inputs/DateTimeInputWidget/types.ts b/src/frontend/src/widgets/inputs/DateTimeInputWidget/types.ts index 620361bcd1..4dc375dea9 100644 --- a/src/frontend/src/widgets/inputs/DateTimeInputWidget/types.ts +++ b/src/frontend/src/widgets/inputs/DateTimeInputWidget/types.ts @@ -40,6 +40,9 @@ export interface BaseVariantProps { autoFocus?: boolean; "data-testid"?: string; onFocusChange?: (open: boolean) => void; + /** When true, clear/invalid render in the parent affix shell instead of an overlay. */ + inAffixShell?: boolean; + trailingBesideSuffix?: boolean; } export interface DateChangeProp { diff --git a/src/frontend/src/widgets/inputs/FeedbackInputWidget.tsx b/src/frontend/src/widgets/inputs/FeedbackInputWidget.tsx index a30a637433..3b79b13eec 100644 --- a/src/frontend/src/widgets/inputs/FeedbackInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/FeedbackInputWidget.tsx @@ -1,13 +1,23 @@ import { EmojiRating } from "@/components/EmojiRating"; import { useEventHandler } from "@/components/event-handler"; +import { InvalidIcon } from "@/components/InvalidIcon"; import { StarRating } from "@/components/StarRating"; import { ThumbsEnum, ThumbsRating } from "@/components/ui/thumbs-rating"; import React, { useCallback, useMemo } from "react"; -import { inputStyles } from "@/lib/styles"; import { cn } from "@/lib/utils"; import { useOptimisticValue } from "./shared/useOptimisticValue"; import { Densities } from "@/types/density"; import { EMPTY_ARRAY } from "@/lib/constants"; +import { boolInputRowMinHeightVariant } from "@/components/ui/input/bool-input-variant"; +import { + normalizeInputDensity, + textInputAffixCellClasses, + textInputAffixInvalidIconClasses, + textInputSizeVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconSizeVariant, +} from "@/components/ui/input/text-input-variant"; interface FeedbackInputWidgetProps { id: string; @@ -42,24 +52,29 @@ export const FeedbackInputWidget: React.FC = ({ const [localValue, setLocalValue] = useOptimisticValue(value, false); + const prefixContent = slots?.Prefix; + const suffixContent = slots?.Suffix; + const hasPrefix = (prefixContent?.length ?? 0) > 0; + const hasSuffix = (suffixContent?.length ?? 0) > 0; + const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showTrailing = Boolean(invalid); + const controlInvalid = trailingBesideSuffix && showTrailing ? undefined : invalid; + const densityKey = normalizeInputDensity(density); + const isBooleanType = useMemo(() => { - // If variant is Thumbs and nullable is true, treat as bool? if (variant === "Thumbs" && nullable) return true; return typeof value === "boolean"; }, [value, variant, nullable]); - // Convert value to number for rating components const numericValue = useMemo(() => { if (localValue === null || localValue === undefined) return ThumbsEnum.None; if (isBooleanType) { if (variant === "Thumbs") { if (nullable) { - // For nullable boolean types: null -> None(0), false -> Down(1), true -> Up(2) - return localValue ? ThumbsEnum.Up : ThumbsEnum.Down; - } else { - // For non-nullable boolean types: false -> Down(1), true -> Up(2) return localValue ? ThumbsEnum.Up : ThumbsEnum.Down; } + return localValue ? ThumbsEnum.Up : ThumbsEnum.Down; } return localValue ? 1 : 0; } @@ -97,7 +112,6 @@ export const FeedbackInputWidget: React.FC = ({ return; } - // For non-nullable boolean types if (e === ThumbsEnum.None || e === numericValue) { convertedValue = !localValue; } else { @@ -138,7 +152,7 @@ export const FeedbackInputWidget: React.FC = ({ disabled={disabled} value={numericValue} onRate={handleChange} - invalid={invalid} + invalid={controlInvalid} density={density} /> ); @@ -150,7 +164,7 @@ export const FeedbackInputWidget: React.FC = ({ disabled={disabled} value={numericValue} onRate={handleChange} - invalid={invalid} + invalid={controlInvalid} allowHalf={allowHalf} totalEmojis={max} density={density} @@ -164,7 +178,7 @@ export const FeedbackInputWidget: React.FC = ({ disabled={disabled} value={numericValue} onRate={handleChange} - invalid={invalid} + invalid={controlInvalid} allowHalf={allowHalf} totalStars={max} density={density} @@ -172,15 +186,9 @@ export const FeedbackInputWidget: React.FC = ({ ); } return null; - }, [variant, disabled, numericValue, handleChange, invalid, allowHalf, max, density]); + }, [variant, disabled, numericValue, handleChange, controlInvalid, allowHalf, max, density]); - const prefixContent = slots?.Prefix; - const suffixContent = slots?.Suffix; - const hasPrefix = (prefixContent?.length ?? 0) > 0; - const hasSuffix = (suffixContent?.length ?? 0) > 0; - const hasAffixes = hasPrefix || hasSuffix; - - const feedbackContent = ( + const feedbackControl = (inAffixShell: boolean) => (
{ if (!e.currentTarget.contains(e.relatedTarget)) { @@ -194,36 +202,76 @@ export const FeedbackInputWidget: React.FC = ({ }} tabIndex={disabled ? -1 : 0} className={cn( - "outline-none focus:outline-none focus:ring-1 focus:ring-ring rounded-md p-1", + "outline-none focus:outline-none focus:ring-1 focus:ring-ring", + !inAffixShell && "rounded-md p-1", disabled && "opacity-50 cursor-not-allowed", - invalid && inputStyles.invalidInput, )} > {ratingComponent}
); - if (!hasAffixes) return feedbackContent; + if (!hasAffixes) { + return feedbackControl(false); + } return (
{ + if (!e.currentTarget.contains(e.relatedTarget)) { + handleBlur(); + } + }} + onFocus={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + handleFocus(); + } + }} > - {hasPrefix && ( -
- {prefixContent} +
+ {hasPrefix && ( +
{prefixContent}
+ )} +
+ {feedbackControl(true)}
- )} -
{feedbackContent}
- {hasSuffix && ( -
- {suffixContent} -
- )} + {hasSuffix && ( +
+ {trailingBesideSuffix && showTrailing && invalid && ( + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )} +
+ )} +
); }; diff --git a/src/frontend/src/widgets/inputs/IconInputWidget.tsx b/src/frontend/src/widgets/inputs/IconInputWidget.tsx index 7a775dc86e..c689418eef 100644 --- a/src/frontend/src/widgets/inputs/IconInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/IconInputWidget.tsx @@ -12,9 +12,21 @@ import { icons } from "lucide-react"; import { X, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Densities } from "@/types/density"; -import { xIconVariant } from "@/components/ui/input/text-input-variant"; +import { boolInputRowMinHeightVariant } from "@/components/ui/input/bool-input-variant"; +import { + normalizeInputDensity, + textInputAffixCellClasses, + textInputAffixInvalidIconClasses, + textInputSizeVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, +} from "@/components/ui/input/text-input-variant"; import { iconInputTriggerVariant, + iconInputAffixTriggerVariant, iconInputIconVariant, iconInputTextVariant, iconInputPopoverVariant, @@ -119,8 +131,12 @@ export const IconInputWidget: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const densityKey = normalizeInputDensity(density); const hasValue = localValue != null && localValue !== "" && localValue !== "None"; + const showClear = nullable && hasValue && !disabled; + const showTrailing = showClear || Boolean(invalid); const valueTextRef = useRef(null); const [isEllipsed, setIsEllipsed] = useState(false); @@ -181,8 +197,35 @@ export const IconInputWidget: React.FC = ({ valueTextSpan ); - const iconContent = ( -
+ const trailingCluster = (inSuffixCluster: boolean) => ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + ); + + const iconField = (inAffixShell: boolean) => ( +
- )} -
+ {!inAffixShell && showTrailing && ( +
{trailingCluster(false)}
)}
); - if (!hasAffixes) return iconContent; + if (!hasAffixes) { + return iconField(false); + } return (
{hasPrefix && ( -
- {prefixContent} -
+
{prefixContent}
)} -
{iconContent}
+
+ {iconField(true)} +
{hasSuffix && ( -
- {suffixContent} +
+ {trailingBesideSuffix && showTrailing && trailingCluster(true)} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/NumberInputWidget.tsx b/src/frontend/src/widgets/inputs/NumberInputWidget.tsx index c5f1f588e4..3c0a489ca8 100644 --- a/src/frontend/src/widgets/inputs/NumberInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/NumberInputWidget.tsx @@ -8,7 +8,16 @@ import { inputStyles, getWidth } from "@/lib/styles"; import { InvalidIcon } from "@/components/InvalidIcon"; import { X } from "lucide-react"; import { Densities } from "@/types/density"; -import { xIconVariant } from "@/components/ui/input/text-input-variant"; +import { + textInputAffixCellClasses, + textInputAffixIconOnlyPaddingVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputTrailingOverlayClasses, +} from "@/components/ui/input/text-input-variant"; import { formatBytes } from "@/lib/formatters"; import { EMPTY_ARRAY } from "@/lib/constants"; @@ -220,15 +229,11 @@ const SliderVariant = memo( )} > {hasPrefix && ( -
- {prefixContent} -
+
{prefixContent}
)} -
{sliderContent}
+
{sliderContent}
{hasSuffix && ( -
- {suffixContent} -
+
{suffixContent}
)}
); @@ -326,6 +331,9 @@ const NumberVariant = memo( const suffixContent = slots?.Suffix; const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; + const showClear = nullable && value !== null && !disabled; + const trailingBesideSuffix = hasSuffix; + const showTrailing = showClear || Boolean(invalid); return (
- {/* Prefix with background and separator */} {hasPrefix && (
{prefixContent}
)} -
+
- {/* Icon container - flex row aligned to right */} - {((nullable && value !== null && !disabled) || invalid) && ( -
- {/* Clear (X) button - leftmost */} - {nullable && value !== null && !disabled && ( + {!trailingBesideSuffix && showTrailing && ( +
+ {showClear && ( )} - {/* Invalid icon - rightmost */} - {invalid && } + {invalid && ( + + )}
)}
- {/* Suffix with background and separator */} {hasSuffix && (
- {suffixContent} + {trailingBesideSuffix && showTrailing && ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/SelectMultiVariant.tsx b/src/frontend/src/widgets/inputs/SelectMultiVariant.tsx index a36ecbec75..ea97f5969c 100644 --- a/src/frontend/src/widgets/inputs/SelectMultiVariant.tsx +++ b/src/frontend/src/widgets/inputs/SelectMultiVariant.tsx @@ -4,7 +4,16 @@ import { MultipleSelector, Option as MultiSelectOption } from "@/components/ui/m import { Loader2, X } from "lucide-react"; import { InvalidIcon } from "@/components/InvalidIcon"; import { logger } from "@/lib/logger"; -import { xIconVariant } from "@/components/ui/input/text-input-variant"; +import { + textInputAffixCellClasses, + textInputAffixIconOnlyPaddingVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + xIconVariant, +} from "@/components/ui/input/text-input-variant"; import { SelectInputWidgetProps, Option } from "./select-types"; import { convertValuesToOriginalType } from "./select-utils"; import { getWidth } from "@/lib/styles"; @@ -132,45 +141,47 @@ export const SelectMultiVariant: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showClear = selectedMultiSelectOptions.length > 0 && !disabled; + const showTrailing = showClear || Boolean(invalid); + + const handleClearAll = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + logger.debug("Select input clear button clicked (MultiSelect)", { id }); + if (events.includes("OnChange")) eventHandler("OnChange", id, [null]); + }; const rightSlot = - loading || (selectedMultiSelectOptions.length > 0 && !disabled) || invalid ? ( + loading || (!trailingBesideSuffix && showTrailing) ? ( <> {loading && ( -
- +
+
)} - {selectedMultiSelectOptions.length > 0 && !disabled && ( + {!trailingBesideSuffix && showClear && ( )} - {invalid && ( -
+ {!trailingBesideSuffix && invalid && ( +
)} @@ -226,14 +237,57 @@ export const SelectMultiVariant: React.FC = ({ )} > {hasPrefix && ( -
+
{prefixContent}
)} -
{multiSelectorContent}
+
{multiSelectorContent}
{hasSuffix && ( -
- {suffixContent} +
+ {trailingBesideSuffix && showTrailing && ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/SelectSingleVariant.tsx b/src/frontend/src/widgets/inputs/SelectSingleVariant.tsx index 6ffbc5858e..b64dfb3220 100644 --- a/src/frontend/src/widgets/inputs/SelectSingleVariant.tsx +++ b/src/frontend/src/widgets/inputs/SelectSingleVariant.tsx @@ -15,7 +15,16 @@ import { Input } from "@/components/ui/input"; import { Search, Loader2, X } from "lucide-react"; import Icon from "@/components/Icon"; import { InvalidIcon } from "@/components/InvalidIcon"; -import { xIconVariant } from "@/components/ui/input/text-input-variant"; +import { + textInputAffixCellClasses, + textInputAffixIconOnlyPaddingVariant, + textInputSuffixGlyphSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + xIconVariant, +} from "@/components/ui/input/text-input-variant"; import { getWidth, inputStyles } from "@/lib/styles"; import { SelectInputWidgetProps } from "./select-types"; import { useSelectValueHandler } from "./select-utils"; @@ -172,6 +181,15 @@ export const SelectSingleVariant: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showClear = nullable && hasValue && !disabled; + const showTrailing = showClear || Boolean(invalid); + + const handleClear = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (events.includes("OnChange")) eventHandler("OnChange", id, [null]); + }; const handleOpenChange = (newOpen: boolean) => { setIsOpen(newOpen); @@ -225,36 +243,30 @@ export const SelectSingleVariant: React.FC = ({ {loading && ( -
- +
+
)} - {nullable && hasValue && !disabled && ( + {!trailingBesideSuffix && showClear && (
{ - e.preventDefault(); - e.stopPropagation(); - if (events.includes("OnChange")) eventHandler("OnChange", id, [null]); - }} + onClick={handleClear} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - if (events.includes("OnChange")) eventHandler("OnChange", id, [null]); + handleClear(e); } }} - className="p-1 rounded hover:bg-accent focus:outline-none cursor-pointer flex items-center h-6 pointer-events-auto" + className="pointer-events-auto flex h-6 cursor-pointer items-center rounded p-1 hover:bg-accent focus:outline-none" >
)} - {invalid && ( + {!trailingBesideSuffix && invalid && (
e.stopPropagation()} > @@ -391,8 +403,10 @@ export const SelectSingleVariant: React.FC = ({ {hasAffixes ? (
= ({ {hasPrefix && (
{prefixContent}
)} -
{selectContent}
+
{selectContent}
{hasSuffix && (
- {suffixContent} + {trailingBesideSuffix && showTrailing && ( + <> + {showClear && ( + + )} + {invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/TextInputWidget.tsx b/src/frontend/src/widgets/inputs/TextInputWidget/TextInputWidget.tsx index ca5445ff9a..e6d0600cf4 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/TextInputWidget.tsx +++ b/src/frontend/src/widgets/inputs/TextInputWidget/TextInputWidget.tsx @@ -230,6 +230,7 @@ export const TextInputWidget: React.FC = ({ onClear={handleClear} onSubmit={handleSubmit} inputRef={inputRef} + isFocused={isFocused} density={density} /> ); diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/__tests__/TextInputWidget.test.ts b/src/frontend/src/widgets/inputs/TextInputWidget/__tests__/TextInputWidget.test.ts index 6b0a491ee1..e7c946726f 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/__tests__/TextInputWidget.test.ts +++ b/src/frontend/src/widgets/inputs/TextInputWidget/__tests__/TextInputWidget.test.ts @@ -36,13 +36,20 @@ vi.mock("@/components/ui/input", () => { }; }); -vi.mock("@/components/ui/input/text-input-variant", () => ({ - textInputSizeVariant: () => "", - textInputAffixCellClasses: () => "", - searchIconVariant: () => "", - xIconVariant: () => "", +const { mockTextInputAffixCellClasses } = vi.hoisted(() => ({ + mockTextInputAffixCellClasses: vi.fn<(side: "prefix" | "suffix", density?: string) => string>( + () => "", + ), })); +vi.mock("@/components/ui/input/text-input-variant", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + textInputAffixCellClasses: mockTextInputAffixCellClasses, + }; +}); + vi.mock("@/lib/styles", () => ({ getWidth: () => ({}), inputStyles: { invalidInput: "invalid-input" }, @@ -542,4 +549,27 @@ describe("SearchVariant disabled/invalid container styles", () => { expect(borderedContainer.className).not.toContain("opacity-50"); expect(borderedContainer.className).not.toContain("border-destructive"); }); + + it("uses shared affix cell classes with field density for prefix and suffix slots", () => { + mockTextInputAffixCellClasses.mockClear(); + renderSearchVariant({ + slots: { + Prefix: [React.createElement("span", { key: "prefix" }, "in:")], + Suffix: [React.createElement("span", { key: "suffix" }, "v")], + }, + }); + + expect(mockTextInputAffixCellClasses).toHaveBeenCalledWith("prefix", "Medium"); + expect(mockTextInputAffixCellClasses).toHaveBeenCalledWith("suffix", "Medium"); + }); + + it("omits the built-in search icon when a prefix slot is present", () => { + renderSearchVariant({ + slots: { + Prefix: [React.createElement("span", { key: "prefix" }, "in:")], + }, + }); + + expect(container.querySelectorAll("svg")).toHaveLength(0); + }); }); diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/variants/DefaultVariant.tsx b/src/frontend/src/widgets/inputs/TextInputWidget/variants/DefaultVariant.tsx index 7b57bc33c4..ca3e1b5779 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/variants/DefaultVariant.tsx +++ b/src/frontend/src/widgets/inputs/TextInputWidget/variants/DefaultVariant.tsx @@ -7,7 +7,13 @@ import { Densities } from "@/types/density"; import { textInputAffixCellClasses, textInputSizeVariant, - xIconVariant, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputSuffixGlyphSlotClasses, + textInputTrailingOverlayClasses, + textInputTrailingShortcutWrapperClasses, } from "@/components/ui/input/text-input-variant"; import { TextInputWidgetProps } from "../types"; import { @@ -73,9 +79,12 @@ export const DefaultVariant: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; - const ghostAffixChrome = Boolean(props.ghost && hasAffixes); const showClear = props.nullable && !props.disabled && hasValue; - const ghostTrailingTight = Boolean(props.ghost && hasSuffix); + const trailingBesideSuffix = hasSuffix; + const showShortcut = Boolean( + props.shortcutKey && !isFocused && !hasValue && !showClear && !props.invalid, + ); + const showTrailing = showShortcut || showClear || Boolean(props.invalid); return (
@@ -91,14 +100,11 @@ export const DefaultVariant: React.FC = ({ "border-transparent shadow-none bg-transparent dark:border-transparent dark:bg-transparent", )} > - {/* Prefix with background and separator */} {hasPrefix && ( -
- {prefixContent} -
+
{prefixContent}
)} -
+
} id={props.id} @@ -118,14 +124,10 @@ export const DefaultVariant: React.FC = ({ className={cn( textInputSizeVariant({ density }), props.invalid && inputStyles.invalidInput, - (props.invalid || showClear) && "pr-8", - props.shortcutKey && - !isFocused && - !hasValue && - !showClear && - !props.invalid && - "pr-16", - showClear && props.invalid && "pr-16", + trailingBesideSuffix && showTrailing && "pr-2", + !trailingBesideSuffix && (props.invalid || showClear) && "pr-8", + !trailingBesideSuffix && showShortcut && "pr-16", + !trailingBesideSuffix && showClear && props.invalid && "pr-16", !hasValue && props.nullable && "placeholder:text-muted-foreground", "border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent", hasPrefix && "rounded-l-none", @@ -135,17 +137,16 @@ export const DefaultVariant: React.FC = ({ data-testid={props["data-testid"]} /> - {/* Right side container: shortcut (if any), clear (if nullable), then invalid (if any) */} - {(props.shortcutKey || showClear || props.invalid) && ( -
- {props.shortcutKey && !isFocused && !hasValue && !showClear && !props.invalid && ( -
- + {!trailingBesideSuffix && showTrailing && ( +
+ {showShortcut && ( +
+ {shortcutDisplay}
@@ -156,16 +157,17 @@ export const DefaultVariant: React.FC = ({ tabIndex={-1} aria-label="Clear" onClick={onClear} - className="pointer-events-auto p-1 rounded hover:bg-accent focus:outline-none cursor-pointer" + className={textInputTrailingIconButtonClasses(true, density)} > - + )} - {/* Invalid icon - rightmost */} {props.invalid && ( -
- -
+ )}
)} @@ -192,10 +194,47 @@ export const DefaultVariant: React.FC = ({ )} - {/* Suffix with background and separator */} {hasSuffix && ( -
- {suffixContent} +
+ {trailingBesideSuffix && showTrailing && ( + <> + {showShortcut && ( + + {shortcutDisplay} + + )} + {showClear && ( + + )} + {props.invalid && ( + + )} + + )} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/variants/PasswordVariant.tsx b/src/frontend/src/widgets/inputs/TextInputWidget/variants/PasswordVariant.tsx index 091ef94e33..09b41f614d 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/variants/PasswordVariant.tsx +++ b/src/frontend/src/widgets/inputs/TextInputWidget/variants/PasswordVariant.tsx @@ -6,9 +6,15 @@ import { getWidth, inputStyles } from "@/lib/styles"; import { InvalidIcon } from "@/components/InvalidIcon"; import { Densities } from "@/types/density"; import { + textInputAffixCellClasses, textInputSizeVariant, - eyeIconVariant, - xIconVariant, + textInputSuffixWithTrailingClusterClasses, + textInputSuffixGlyphSlotClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputTrailingOverlayClasses, + textInputTrailingShortcutWrapperClasses, } from "@/components/ui/input/text-input-variant"; import { TextInputWidgetProps } from "../types"; import { @@ -27,6 +33,7 @@ interface PasswordVariantProps { onSubmit?: () => void; width?: string; inputRef?: React.RefObject; + isFocused: boolean; density?: Densities; } @@ -38,6 +45,7 @@ export const PasswordVariant: React.FC = ({ onClear, onSubmit, inputRef, + isFocused, density = Densities.Medium, }) => { const [showPassword, setShowPassword] = useState(false); @@ -83,105 +91,153 @@ export const PasswordVariant: React.FC = ({ const hasValue = props.value && props.value.toString().trim() !== ""; const showClear = props.nullable && !props.disabled && hasValue; const ghostTight = Boolean(props.ghost); + const prefixContent = props.slots?.Prefix; + const suffixContent = props.slots?.Suffix; + const hasPrefix = (prefixContent?.length ?? 0) > 0; + const hasSuffix = (suffixContent?.length ?? 0) > 0; + const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showPasswordToggle = !hasLastPass; + const showShortcut = Boolean(props.shortcutKey && !hasValue && !showClear && !props.invalid); + const showTrailing = showPasswordToggle || showClear || showShortcut || Boolean(props.invalid); + + const trailingCluster = (overlay: boolean) => ( + <> + {showPasswordToggle && ( + + )} + {showClear && ( + + )} + {showShortcut && ( +
+ + {shortcutDisplay} + +
+ )} + {props.invalid && ( + + )} + + ); return (
- -
- {!hasLastPass && ( -
-
- -
- {showClear && ( - - )} - {props.shortcutKey && !hasValue && !showClear && !props.invalid && ( -
- - {shortcutDisplay} - -
- )} - {props.invalid && ( -
- -
+ {hasPrefix && ( +
{prefixContent}
+ )} + +
+ + + {!trailingBesideSuffix && showTrailing && ( +
{trailingCluster(true)}
)}
- )} + + {hasSuffix && ( +
+ {trailingBesideSuffix && showTrailing && trailingCluster(false)} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )} +
+ )} +
); }; diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/variants/SearchVariant.tsx b/src/frontend/src/widgets/inputs/TextInputWidget/variants/SearchVariant.tsx index da88119031..037bbfb6ae 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/variants/SearchVariant.tsx +++ b/src/frontend/src/widgets/inputs/TextInputWidget/variants/SearchVariant.tsx @@ -10,8 +10,13 @@ import { Densities } from "@/types/density"; import { textInputAffixCellClasses, textInputSizeVariant, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, + textInputSuffixWithTrailingClusterClasses, + textInputSuffixGlyphSlotClasses, + textInputTrailingShortcutWrapperClasses, searchIconVariant, - xIconVariant, } from "@/components/ui/input/text-input-variant"; import { TextInputWidgetProps } from "../types"; import { useCursorPosition, usePasteHandler, formatShortcutForDisplay } from "../hooks"; @@ -89,8 +94,9 @@ export const SearchVariant: React.FC = ({ const hasPrefix = (prefixContent?.length ?? 0) > 0; const hasSuffix = (suffixContent?.length ?? 0) > 0; const hasAffixes = hasPrefix || hasSuffix; - const ghostAffixChrome = Boolean(props.ghost && hasAffixes); - const ghostSuffixLayout = Boolean(props.ghost && hasSuffix); + /** Trailing controls sit beside the suffix affix, not inside padded input text. */ + const trailingBesideSuffix = hasSuffix; + const showBuiltinSearchIcon = !hasPrefix; const showClear = !props.disabled && hasValue; const showShortcut = Boolean(props.shortcutKey) && !isFocused && !hasValue && !showClear && !props.invalid; @@ -120,23 +126,27 @@ export const SearchVariant: React.FC = ({ tabIndex={-1} aria-label="Clear search" onClick={onClear} - className={cn( - "flex h-6 shrink-0 items-center rounded hover:bg-accent focus:outline-none cursor-pointer", - overlay ? "p-1 pointer-events-auto" : "p-0.5", - )} + className={textInputTrailingIconButtonClasses(overlay, density)} > - + )} {showShortcut && ( -
+
{kbd}
)} {props.invalid && ( -
- -
+ )} ); @@ -156,13 +166,11 @@ export const SearchVariant: React.FC = ({ )} > {hasPrefix && ( -
- {prefixContent} -
+
{prefixContent}
)} -
- +
+ {showBuiltinSearchIcon && } = ({ autoComplete="off" className={cn( textInputSizeVariant({ density }), - "pl-8 cursor-pointer border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent", + "cursor-pointer border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent", + showBuiltinSearchIcon && "pl-8", props.invalid && inputStyles.invalidInput, - ghostSuffixLayout && showTrailing && "pr-2", - !ghostSuffixLayout && (props.invalid || showClear) && "pr-8", - !ghostSuffixLayout && - props.shortcutKey && - !isFocused && - !hasValue && - !showClear && - !props.invalid && - "pr-16", - !ghostSuffixLayout && showClear && props.invalid && "pr-16", + trailingBesideSuffix && showTrailing && "pr-2", + !trailingBesideSuffix && (props.invalid || showClear) && "pr-8", + !trailingBesideSuffix && showShortcut && "pr-16", + !trailingBesideSuffix && showClear && props.invalid && "pr-16", !hasValue && props.nullable && "placeholder:text-muted-foreground", "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-cancel-button]:hidden", hasPrefix && "rounded-l-none", @@ -202,22 +205,28 @@ export const SearchVariant: React.FC = ({ )} data-testid={props["data-testid"]} /> - {!ghostSuffixLayout && showTrailing && ( + {!trailingBesideSuffix && showTrailing && (
{trailingCluster(true)}
)}
- {ghostSuffixLayout && showTrailing && ( -
- {trailingCluster(false)} -
- )} - {hasSuffix && ( -
- {suffixContent} +
+ {trailingBesideSuffix && showTrailing && trailingCluster(false)} + {trailingBesideSuffix && showTrailing ? ( + {suffixContent} + ) : ( + suffixContent + )}
)}
diff --git a/src/frontend/src/widgets/inputs/TextInputWidget/variants/TextareaVariant.tsx b/src/frontend/src/widgets/inputs/TextInputWidget/variants/TextareaVariant.tsx index f98c3595dd..ebcca260dd 100644 --- a/src/frontend/src/widgets/inputs/TextInputWidget/variants/TextareaVariant.tsx +++ b/src/frontend/src/widgets/inputs/TextInputWidget/variants/TextareaVariant.tsx @@ -4,7 +4,17 @@ import { cn } from "@/lib/utils"; import { getWidth, getHeight, inputStyles } from "@/lib/styles"; import { InvalidIcon } from "@/components/InvalidIcon"; import { Densities } from "@/types/density"; -import { textareaSizeVariant, xIconVariant } from "@/components/ui/input/text-input-variant"; +import { + textareaSizeVariant, + textareaTrailingOverlayClasses, + textareaSuffixWithTrailingClusterClasses, + textareaTrailingStackClasses, + textInputAffixCellClasses, + textInputSuffixGlyphSlotClasses, + textInputTrailingIconButtonClasses, + textInputTrailingIconSizeVariant, + textInputTrailingInvalidSlotClasses, +} from "@/components/ui/input/text-input-variant"; import { TextInputWidgetProps } from "../types"; import { useCursorPosition, usePasteHandler, formatShortcutForDisplay } from "../hooks"; import { Mic, X } from "lucide-react"; @@ -27,6 +37,14 @@ interface TextareaVariantProps { density?: Densities; } +const textareaAffixAlignClasses = (density: Densities) => + cn( + "self-start", + density === Densities.Small && "pt-2", + density === Densities.Medium && "pt-2", + density === Densities.Large && "pt-3", + ); + export const TextareaVariant: React.FC = ({ props, onChange, @@ -71,86 +89,149 @@ export const TextareaVariant: React.FC = ({ const shortcutDisplay = formatShortcutForDisplay(props.shortcutKey); const hasValue = props.value && props.value.toString().trim() !== ""; const showClear = props.nullable && !props.disabled && hasValue; + const prefixContent = props.slots?.Prefix; + const suffixContent = props.slots?.Suffix; + const hasPrefix = (prefixContent?.length ?? 0) > 0; + const hasSuffix = (suffixContent?.length ?? 0) > 0; + const hasAffixes = hasPrefix || hasSuffix; + const trailingBesideSuffix = hasSuffix; + const showShortcut = Boolean( + props.shortcutKey && !isFocused && !hasValue && !showClear && !props.invalid, + ); + const showTrailing = + showShortcut || showClear || Boolean(props.invalid) || Boolean(props.dictation); + + const trailingCluster = (overlay: boolean) => ( + <> + {props.dictation && !props.disabled && ( + + )} + {showClear && ( + + )} + {showShortcut && ( +
+ + {shortcutDisplay} + +
+ )} + {props.invalid && ( + + )} + + ); return ( -
+
-