diff --git a/base/src/main/java/atlantafx/base/controls/Message.java b/base/src/main/java/atlantafx/base/controls/Message.java new file mode 100644 index 0000000..0447384 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/Message.java @@ -0,0 +1,35 @@ +/* SPDX-License-Identifier: MIT */ + +package atlantafx.base.controls; + +import javafx.scene.Node; + +/** + * The Message is a component for displaying notifications or alerts + * and is specifically designed to grab the user’s attention. + * It is based on the Tile layout and shares its structure. + */ +public class Message extends Tile { + + /** + * See {@link Tile#Tile()}. + */ + public Message() { + super(null, null, null); + } + + /** + * See {@link Tile#Tile(String, String)}. + */ + public Message(String title, String subtitle) { + this(title, subtitle, null); + } + + /** + * See {@link Tile#Tile(String, String, Node)}. + */ + public Message(String title, String subtitle, Node graphic) { + super(title, subtitle, graphic); + getStyleClass().add("message"); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/TileSkin.java b/base/src/main/java/atlantafx/base/controls/TileSkin.java index 5e8a950..e7c34ab 100644 --- a/base/src/main/java/atlantafx/base/controls/TileSkin.java +++ b/base/src/main/java/atlantafx/base/controls/TileSkin.java @@ -16,7 +16,8 @@ import javafx.scene.layout.VBox; public class TileSkin extends SkinBase { - private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled"); + private static final PseudoClass WITH_TITLE = PseudoClass.getPseudoClass("with-title"); + private static final PseudoClass WITH_SUBTITLE = PseudoClass.getPseudoClass("with-subtitle"); protected final HBox root = new HBox(); protected final StackPane graphicSlot; @@ -38,7 +39,8 @@ public class TileSkin extends SkinBase { titleLbl = new Label(control.getTitle()); titleLbl.getStyleClass().add("title"); - titleLbl.textProperty().bind(control.titleProperty()); + titleLbl.setVisible(control.getTitle() != null); + titleLbl.setManaged(control.getTitle() != null); subTitleLbl = new Label(control.getSubTitle()); subTitleLbl.setWrapText(true); @@ -53,17 +55,24 @@ public class TileSkin extends SkinBase { headerBox.setMinHeight(Region.USE_COMPUTED_SIZE); headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE); headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE); - headerBox.pseudoClassStateChanged(FILLED, control.getSubTitle() != null); + + root.pseudoClassStateChanged(WITH_TITLE, control.getTitle() != null); + registerChangeListener(control.titleProperty(), o -> { + var value = getSkinnable().getSubTitle(); + titleLbl.setText(value); + titleLbl.setVisible(value != null); + titleLbl.setManaged(value != null); + root.pseudoClassStateChanged(WITH_TITLE, value != null); + }); + + root.pseudoClassStateChanged(WITH_SUBTITLE, control.getSubTitle() != null); registerChangeListener(control.subTitleProperty(), o -> { var value = getSkinnable().getSubTitle(); subTitleLbl.setText(value); subTitleLbl.setVisible(value != null); subTitleLbl.setManaged(value != null); - - // header is considered to be “filled” when a subtitle is set - // because a tile without a title is nonsense - headerBox.pseudoClassStateChanged(FILLED, value != null); + root.pseudoClassStateChanged(WITH_SUBTITLE, value != null); }); actionSlot = new StackPane(); @@ -125,7 +134,7 @@ public class TileSkin extends SkinBase { @Override public void dispose() { - titleLbl.textProperty().unbind(); + unregisterChangeListeners(getSkinnable().titleProperty()); unregisterChangeListeners(getSkinnable().subTitleProperty()); getSkinnable().graphicProperty().removeListener(graphicSlotListener); getSkinnable().actionProperty().removeListener(actionSlotListener); diff --git a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java index ef03970..6b8d54b 100644 --- a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java +++ b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java @@ -27,6 +27,7 @@ import atlantafx.sampler.page.components.InputGroupPage; import atlantafx.sampler.page.components.ListViewPage; import atlantafx.sampler.page.components.MenuBarPage; import atlantafx.sampler.page.components.MenuButtonPage; +import atlantafx.sampler.page.components.MessagePage; import atlantafx.sampler.page.components.ModalPanePage; import atlantafx.sampler.page.components.PaginationPage; import atlantafx.sampler.page.components.PopoverPage; @@ -184,6 +185,7 @@ public class MainModel { var feedback = NavTree.Item.group("Feedback", new FontIcon(Material2OutlinedAL.CHAT_BUBBLE_OUTLINE)); feedback.getChildren().setAll( NAV_TREE.get(DialogPage.class), + NAV_TREE.get(MessagePage.class), NAV_TREE.get(ProgressIndicatorPage.class), NAV_TREE.get(TooltipPage.class) ); @@ -289,6 +291,7 @@ public class MainModel { MenuButtonPage.NAME, MenuButtonPage.class, "SplitMenuButton") ); + map.put(MessagePage.class, NavTree.Item.page(MessagePage.NAME, MessagePage.class)); map.put(ModalPanePage.class, NavTree.Item.page(ModalPanePage.NAME, ModalPanePage.class)); map.put(PaginationPage.class, NavTree.Item.page(PaginationPage.NAME, PaginationPage.class)); map.put(PopoverPage.class, NavTree.Item.page(PopoverPage.NAME, PopoverPage.class)); diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java b/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java new file mode 100644 index 0000000..845ed0a --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/components/MessagePage.java @@ -0,0 +1,181 @@ +package atlantafx.sampler.page.components; + +import atlantafx.base.controls.Message; +import atlantafx.base.theme.Styles; +import atlantafx.base.theme.Tweaks; +import atlantafx.base.util.Animations; +import atlantafx.base.util.BBCodeParser; +import atlantafx.sampler.page.ExampleBox; +import atlantafx.sampler.page.OutlinePage; +import atlantafx.sampler.page.Snippet; +import java.net.URI; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.util.Duration; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.material2.Material2OutlinedAL; +import org.kordamp.ikonli.material2.Material2OutlinedMZ; + +public class MessagePage extends OutlinePage { + + public static final String NAME = "Message"; + + @Override + public String getName() { + return NAME; + } + + @Override + public URI getJavadocUri() { + return URI.create(String.format(AFX_JAVADOC_URI_TEMPLATE, "controls/" + getName())); + } + + public MessagePage() { + super(); + + addPageHeader(); + addFormattedText(""" + The [i]Message[/i] is a component for displaying notifications or alerts \ + and is specifically designed to grab the user’s attention. It is \ + based on the [i]Tile[/i] layout and shares its structure.""" + ); + addSection("Usage", usageExample()); + addSection("No Title", noTitleExample()); + addSection("Interactive", interactiveExample()); + addSection("Banner", bannerExample()); + } + + private Node usageExample() { + //snippet_1:start + var info = new Message( + "Info", + FAKER.lorem().sentence(5), + new FontIcon(Material2OutlinedAL.HELP_OUTLINE) + ); + + var success = new Message( + "Success", + FAKER.lorem().sentence(10), + new FontIcon(Material2OutlinedAL.CHECK_CIRCLE_OUTLINE) + ); + success.getStyleClass().add(Styles.SUCCESS); + + var warning = new Message( + "Warning", + FAKER.lorem().sentence(20), + new FontIcon(Material2OutlinedMZ.OUTLINED_FLAG) + ); + warning.getStyleClass().add(Styles.WARNING); + + var danger = new Message( + "Danger", + FAKER.lorem().sentence(25), + new FontIcon(Material2OutlinedAL.ERROR_OUTLINE) + ); + danger.getStyleClass().add(Styles.DANGER); + //snippet_1:end + + var box = new VBox(VGAP_10, info, success, warning, danger); + var description = BBCodeParser.createFormattedText(""" + The default [i]Message[/i] type is "info", which corresponds to the \ + accent color. Success, warning, and danger colors are also supported.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 1), description); + } + + private Node noTitleExample() { + //snippet_2:start + var info1 = new Message( + null, + FAKER.lorem().sentence(5), + new FontIcon(Material2OutlinedAL.HELP_OUTLINE) + ); + + var info2 = new Message( + null, + FAKER.lorem().sentence(15) + ); + + var info3 = new Message( + null, + FAKER.lorem().sentence(50) + ); + var btn = new Button("Done"); + btn.getStyleClass().add(Styles.ACCENT); + info3.setAction(btn); + //snippet_2:end + + var box = new VBox(VGAP_10, info1, info2, info3); + var description = BBCodeParser.createFormattedText(""" + Unlike the [i]Tile[/i], the message title is optional. This example \ + demonstrates various messages without a title.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 2), description); + } + + private Node interactiveExample() { + //snippet_3:start + var msg = new Message( + "Success", + FAKER.lorem().sentence(25) + ); + msg.getStyleClass().add(Styles.SUCCESS); + + var btn = new Button("Undo"); + btn.getStyleClass().addAll(Styles.SUCCESS); + + msg.setAction(btn); + msg.setActionHandler(() -> Animations.flash(msg).playFromStart()); + //snippet_3:end + + var box = new VBox(msg); + box.setPadding(new Insets(0, 0, 5, 0)); + var description = BBCodeParser.createFormattedText(""" + A [i]Message[/i] can be made interactive by setting an action handler that may \ + or may not be related to the action slot.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 3), description); + } + + private Node bannerExample() { + //snippet_4:start + final var msg = new Message( + null, + FAKER.lorem().sentence(10) + ); + msg.getStyleClass().addAll( + Styles.DANGER, Tweaks.EDGE_TO_EDGE + ); + + var closeBtn = new Button("Close"); + closeBtn.getStyleClass().addAll(Styles.DANGER); + msg.setAction(closeBtn); + + var showBannerBtn = new Button("Show banner"); + showBannerBtn.setOnAction(e1 -> { + var parent = (BorderPane) getScene().lookup("#main"); + + parent.setTop(new VBox(msg)); + closeBtn.setOnAction(e2 -> parent.setTop(null)); + + msg.setOpacity(0); + Animations.fadeInDown(msg, Duration.millis(350)) + .playFromStart(); + }); + //snippet_4:end + + var box = new VBox(showBannerBtn); + var description = BBCodeParser.createFormattedText(""" + The [i]Message[/i] supports the [code]Tweaks.EDGE_TO_EDGE[/code] style class modifier, \ + which can be used to create a fancy banner, for example.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 4), description); + } +} diff --git a/styles/src/components/_index.scss b/styles/src/components/_index.scss index c415c52..f1dd020 100755 --- a/styles/src/components/_index.scss +++ b/styles/src/components/_index.scss @@ -17,6 +17,7 @@ @use "label"; @use "menu"; @use "menu-button"; +@use "message"; @use "modal-pane"; @use "pagination"; @use "popover"; diff --git a/styles/src/components/_message.scss b/styles/src/components/_message.scss new file mode 100644 index 0000000..5ea80b2 --- /dev/null +++ b/styles/src/components/_message.scss @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT + +@use "../settings/config" as cfg; + +$color-bg-accent: -color-accent-subtle !default; +$color-fg-accent-primary: -color-accent-fg !default; +$color-fg-accent-secondary: -color-fg-default !default; +$color-border-accent: -color-accent-muted !default; +$color-border-accent-hover: -color-accent-emphasis !default; + +$color-bg-success: -color-success-subtle !default; +$color-fg-success-primary: -color-success-fg !default; +$color-fg-success-secondary: -color-fg-default !default; +$color-border-success: -color-success-muted !default; +$color-border-success-hover: -color-success-emphasis !default; + +$color-bg-warning: -color-warning-subtle !default; +$color-fg-warning-primary: -color-warning-fg !default; +$color-fg-warning-secondary: -color-fg-default !default; +$color-border-warning: -color-warning-muted !default; +$color-border-warning-hover: -color-warning-emphasis !default; + +$color-bg-danger: -color-danger-subtle !default; +$color-fg-danger-primary: -color-danger-fg !default; +$color-fg-danger-secondary: -color-fg-default !default; +$color-border-danger: -color-danger-muted !default; +$color-border-danger-hover: -color-danger-emphasis !default; + +.message { + + -color-message-bg: $color-bg-accent; + -color-message-fg-primary: $color-fg-accent-primary; + -color-message-fg-secondary: $color-fg-accent-secondary; + -color-message-border: $color-border-accent; + -color-message-border-interactive: $color-border-accent-hover; + + &.success { + -color-message-bg: $color-bg-success; + -color-message-fg-primary: $color-fg-success-primary; + -color-message-fg-secondary: $color-fg-success-secondary; + -color-message-border: $color-border-success; + -color-message-border-interactive: $color-border-success-hover; + } + + &.warning { + -color-message-bg: $color-bg-warning; + -color-message-fg-primary: $color-fg-warning-primary; + -color-message-fg-secondary: $color-fg-warning-secondary; + -color-message-border: $color-border-warning; + -color-message-border-interactive: $color-border-warning-hover; + } + + &.danger { + -color-message-bg: $color-bg-danger; + -color-message-fg-primary: $color-fg-danger-primary; + -color-message-fg-secondary: $color-fg-danger-secondary; + -color-message-border: $color-border-danger; + -color-message-border-interactive: $color-border-danger-hover; + } + + >.tile { + -fx-background-color: -color-message-bg; + -fx-alignment: TOP_LEFT; + -fx-border-color: -color-message-border; + -fx-border-width: cfg.$border-width; + -fx-border-radius: cfg.$border-radius; + + &:hover:interactive { + -fx-background-color: -color-message-bg; + -fx-border-color: -color-message-border-interactive; + } + + &:with-title:with-subtitle { + -fx-alignment: TOP_LEFT; + } + + &:with-title, + &:with-subtitle { + -fx-alignment: CENTER_LEFT; + } + + >.graphic { + -fx-alignment: TOP_LEFT; + } + + >.header { + >.title { + -fx-text-fill: -color-message-fg-primary; + } + + >.subtitle { + -fx-text-fill: -color-message-fg-secondary; + } + } + + >.action { + -fx-alignment: TOP_LEFT; + } + + #{cfg.$font-icon-selector} { + -fx-icon-color: -color-message-fg-primary; + -fx-fill: -color-message-fg-primary; + -fx-icon-size: cfg.$icon-size-larger; + } + } + + &.edge-to-edge > .tile { + -fx-border-width: 0; + -fx-border-radius: 0; + } +} diff --git a/styles/src/components/_tile.scss b/styles/src/components/_tile.scss index b21f3bc..c4556d2 100644 --- a/styles/src/components/_tile.scss +++ b/styles/src/components/_tile.scss @@ -31,8 +31,10 @@ $color-interactive: -color-bg-subtle !default; >.subtitle { -fx-text-fill: -color-fg-muted; } + } - &:filled { + &:with-title:with-subtitle { + >.header { -fx-spacing: 0.5em; -fx-alignment: TOP_LEFT; } diff --git a/styles/src/settings/_config.scss b/styles/src/settings/_config.scss index e21e177..b537d95 100644 --- a/styles/src/settings/_config.scss +++ b/styles/src/settings/_config.scss @@ -36,7 +36,8 @@ $font-icon-selector: ".font-icon, .ikonli-font-icon"; $font-icon-selector-immediate: ">.font-icon, >.ikonli-font-icon"; // Ikonli doesn't support 'em' -$icon-size: 18px !default; +$icon-size: 18px !default; +$icon-size-larger: 24px !default; // elevation (level : radius) $elevation: (1: 2px, 2: 8px, 3: 16px, 4: 20px) !default;