Refactor and improve Tile-based controls

This commit is contained in:
mkpaz 2023-05-29 07:44:27 +04:00
parent 998bd69334
commit 34acefa8f8
16 changed files with 738 additions and 371 deletions

@ -2,34 +2,93 @@
package atlantafx.base.controls;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import org.jetbrains.annotations.Nullable;
/**
* The Message is a component for displaying notifications or alerts
* and is specifically designed to grab the users attention.
* It is based on the Tile layout and shares its structure.
*/
public class Message extends Tile {
public class Message extends TileBase {
/**
* See {@link Tile#Tile()}.
*/
public Message() {
super(null, null, null);
this(null, null, null);
}
/**
* See {@link Tile#Tile(String, String)}.
*/
public Message(String title, String subtitle) {
this(title, subtitle, null);
public Message(@Nullable @NamedArg("title") String title,
@Nullable @NamedArg("description") String description) {
this(title, description, null);
}
/**
* See {@link Tile#Tile(String, String, Node)}.
*/
public Message(String title, String subtitle, Node graphic) {
super(title, subtitle, graphic);
public Message(@Nullable String title,
@Nullable String description,
@Nullable Node graphic) {
super(title, description, graphic);
getStyleClass().add("message");
}
@Override
protected Skin<?> createDefaultSkin() {
return new MessageSkin(this);
}
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
/**
* The property representing the messages action handler. Setting an action handler
* makes the message interactive or clickable. When a user clicks on the interactive
* message, the specified action handler will be called.
*/
private final ObjectProperty<Runnable> actionHandler = new SimpleObjectProperty<>(this, "actionHandler");
public Runnable getActionHandler() {
return actionHandler.get();
}
public ObjectProperty<Runnable> actionHandlerProperty() {
return actionHandler;
}
public void setActionHandler(Runnable actionHandler) {
this.actionHandler.set(actionHandler);
}
/**
* The property representing the user specified close handler. Note that
* if you have also specified the ModalPane instance or CSS selector, this
* handler will be executed after the default close handler. Therefore, you
* can use it to perform arbitrary actions on dialog close.
*/
protected final ObjectProperty<EventHandler<? super Event>> onClose =
new SimpleObjectProperty<>(this, "onClose");
public EventHandler<? super Event> getOnClose() {
return onClose.get();
}
public ObjectProperty<EventHandler<? super Event>> onCloseProperty() {
return onClose;
}
public void setOnClose(EventHandler<? super Event> onClose) {
this.onClose.set(onClose);
}
}

@ -0,0 +1,78 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import atlantafx.base.theme.Styles;
import javafx.css.PseudoClass;
import javafx.event.Event;
import javafx.geometry.HPos;
import javafx.geometry.VPos;
import javafx.scene.layout.StackPane;
public class MessageSkin extends TileSkinBase<Message> {
private static final PseudoClass CLOSEABLE = PseudoClass.getPseudoClass("closeable");
protected final StackPane closeButton = new StackPane();
protected final StackPane closeButtonIcon = new StackPane();
public MessageSkin(Message control) {
super(control);
// ACTION
pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null);
registerChangeListener(
control.actionHandlerProperty(),
o -> pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null)
);
root.setOnMouseClicked(e -> {
if (getSkinnable().getActionHandler() != null) {
getSkinnable().getActionHandler().run();
}
});
// CLOSE BUTTON
closeButton.getStyleClass().add("close-button");
closeButton.getChildren().setAll(closeButtonIcon);
closeButton.setOnMouseClicked(e -> handleClose());
closeButton.setVisible(control.getOnClose() != null);
closeButton.setManaged(control.getOnClose() != null);
closeButtonIcon.getStyleClass().add("icon");
getChildren().add(closeButton);
pseudoClassStateChanged(CLOSEABLE, control.getOnClose() != null);
registerChangeListener(control.onCloseProperty(), o -> {
closeButton.setVisible(getSkinnable().getOnClose() != null);
closeButton.setManaged(getSkinnable().getOnClose() != null);
pseudoClassStateChanged(CLOSEABLE, getSkinnable().onCloseProperty() != null);
});
}
protected void handleClose() {
if (getSkinnable().getOnClose() != null) {
getSkinnable().getOnClose().handle(new Event(Event.ANY));
}
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
if (closeButton.isManaged()) {
var lb = closeButton.getLayoutBounds();
layoutInArea(closeButton, w - lb.getWidth() - 5, 5, lb.getWidth(), lb.getHeight(), -1, HPos.RIGHT,
VPos.TOP);
}
layoutInArea(root, x, y, w, h, -1, HPos.CENTER, VPos.CENTER);
}
@Override
public void dispose() {
unregisterChangeListeners(getSkinnable().actionHandlerProperty());
unregisterChangeListeners(getSkinnable().onCloseProperty());
super.dispose();
}
}

@ -3,21 +3,30 @@
package atlantafx.base.controls;
import java.util.Objects;
import java.util.function.BiConsumer;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import org.jetbrains.annotations.Nullable;
final class SlotListener implements ChangeListener<Node> {
private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled");
private final Pane slot;
private final @Nullable BiConsumer<Node, Boolean> onContentUpdate;
public SlotListener(Node slot) {
public SlotListener(Pane slot) {
this(slot, null);
}
public SlotListener(Node slot, @Nullable BiConsumer<Node, Boolean> onContentUpdate) {
Objects.requireNonNull(slot, "Slot cannot be null.");
this.onContentUpdate = onContentUpdate;
if (slot instanceof Pane pane) {
this.slot = pane;
} else {
@ -35,5 +44,9 @@ final class SlotListener implements ChangeListener<Node> {
slot.setVisible(val != null);
slot.setManaged(val != null);
slot.pseudoClassStateChanged(FILLED, val != null);
if (onContentUpdate != null) {
onContentUpdate.accept(val, val != null);
}
}
}

@ -5,35 +5,31 @@ package atlantafx.base.controls;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import org.jetbrains.annotations.Nullable;
/**
* A versatile container that can used in various contexts such as dialog headers,
* list items, and cards. It can contain a graphic, a title, subtitle, and optional
* list items, and cards. It can contain a graphic, a title, description, and optional
* actions.
*/
public class Tile extends Control {
public class Tile extends TileBase {
public Tile() {
this(null, null, null);
}
public Tile(@NamedArg("title") String title,
@NamedArg("subTitle") String subTitle) {
this(title, subTitle, null);
public Tile(@Nullable @NamedArg("title") String title,
@Nullable @NamedArg("description") String description) {
this(title, description, null);
}
public Tile(@NamedArg("title") String title,
@NamedArg("subTitle") String subTitle,
@NamedArg("graphic") Node graphic) {
super();
setTitle(title);
setSubTitle(subTitle);
setGraphic(graphic);
public Tile(@Nullable String title,
@Nullable String description,
@Nullable Node graphic) {
super(title, description, graphic);
getStyleClass().add("tile");
}
@Override
@ -45,60 +41,6 @@ public class Tile extends Control {
// Properties //
///////////////////////////////////////////////////////////////////////////
/**
* The property representing the tiles graphic node. It is commonly used
* to add images or icons that are associated with the tile.
*/
private final ObjectProperty<Node> graphic = new SimpleObjectProperty<>(this, "graphic");
public Node getGraphic() {
return graphic.get();
}
public ObjectProperty<Node> graphicProperty() {
return graphic;
}
public void setGraphic(Node graphic) {
this.graphic.set(graphic);
}
/**
* The property representing the tiles title. Although it is not mandatory,
* you typically would not want to have a tile without a title.
*/
private final StringProperty title = new SimpleStringProperty(this, "title");
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
/**
* The property representing the tiles subtitle. This is usually an optional
* text or a description.
*/
private final StringProperty subTitle = new SimpleStringProperty(this, "subTitle");
public String getSubTitle() {
return subTitle.get();
}
public StringProperty subTitleProperty() {
return subTitle;
}
public void setSubTitle(String subTitle) {
this.subTitle.set(subTitle);
}
/**
* The property representing the tiles action node. It is commonly used
* to place an action controls that are associated with the tile.

@ -0,0 +1,92 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Node;
import javafx.scene.control.Control;
import org.jetbrains.annotations.Nullable;
public abstract class TileBase extends Control {
public TileBase() {
this(null, null, null);
}
public TileBase(@Nullable @NamedArg("title") String title,
@Nullable @NamedArg("description") String description) {
this(title, description, null);
}
public TileBase(@Nullable String title,
@Nullable String description,
@Nullable Node graphic) {
super();
setTitle(title);
setDescription(description);
setGraphic(graphic);
getStyleClass().add("tile-base");
}
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
/**
* The property representing the tiles graphic node. It is commonly used
* to add images or icons that are associated with the tile.
*/
private final ObjectProperty<Node> graphic = new SimpleObjectProperty<>(this, "graphic");
public Node getGraphic() {
return graphic.get();
}
public ObjectProperty<Node> graphicProperty() {
return graphic;
}
public void setGraphic(Node graphic) {
this.graphic.set(graphic);
}
/**
* The property representing the tiles title. Although it is not mandatory,
* you typically would not want to have a tile without a title.
*/
private final StringProperty title = new SimpleStringProperty(this, "title");
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
/**
* The property representing the tiles description.
*/
private final StringProperty description = new SimpleStringProperty(this, "description");
public String getDescription() {
return description.get();
}
public StringProperty descriptionProperty() {
return description;
}
public void setDescription(String description) {
this.description.set(description);
}
}

@ -3,140 +3,30 @@
package atlantafx.base.controls;
import atlantafx.base.theme.Styles;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
public class TileSkin extends SkinBase<Tile> {
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;
protected final ChangeListener<Node> graphicSlotListener;
protected final VBox headerBox;
protected final Label titleLbl;
protected final Label subTitleLbl;
protected final StackPane actionSlot;
protected final ChangeListener<Node> actionSlotListener;
public class TileSkin extends TileSkinBase<Tile> {
public TileSkin(Tile control) {
super(control);
graphicSlot = new StackPane();
graphicSlot.getStyleClass().add("graphic");
graphicSlotListener = new SlotListener(graphicSlot);
control.graphicProperty().addListener(graphicSlotListener);
graphicSlotListener.changed(control.graphicProperty(), null, control.getGraphic());
titleLbl = new Label(control.getTitle());
titleLbl.getStyleClass().add("title");
titleLbl.setVisible(control.getTitle() != null);
titleLbl.setManaged(control.getTitle() != null);
subTitleLbl = new Label(control.getSubTitle());
subTitleLbl.setWrapText(true);
subTitleLbl.getStyleClass().add("subtitle");
subTitleLbl.setVisible(control.getSubTitle() != null);
subTitleLbl.setManaged(control.getSubTitle() != null);
headerBox = new VBox(titleLbl, subTitleLbl);
headerBox.setFillWidth(true);
headerBox.getStyleClass().add("header");
HBox.setHgrow(headerBox, Priority.ALWAYS);
headerBox.setMinHeight(Region.USE_COMPUTED_SIZE);
headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE);
headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE);
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);
root.pseudoClassStateChanged(WITH_SUBTITLE, value != null);
});
actionSlot = new StackPane();
actionSlot.getStyleClass().add("action");
actionSlotListener = new SlotListener(actionSlot);
control.actionProperty().addListener(actionSlotListener);
actionSlotListener.changed(control.actionProperty(), null, control.getAction());
// use pref size for slots, or they will be resized
// to the bare minimum due to Priority.ALWAYS
graphicSlot.setMinWidth(Region.USE_PREF_SIZE);
actionSlot.setMinWidth(Region.USE_PREF_SIZE);
// label text wrapping inside VBox won't work without this
subTitleLbl.setMaxWidth(Region.USE_PREF_SIZE);
subTitleLbl.setMinHeight(Region.USE_PREF_SIZE);
// do not resize children or container won't restore
// to its original size after expanding
root.setFillHeight(false);
root.getStyleClass().add("tile");
root.getChildren().setAll(graphicSlot, headerBox, actionSlot);
root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null);
pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null);
registerChangeListener(
control.actionHandlerProperty(),
o -> root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null)
o -> pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null)
);
root.setOnMouseClicked(e -> {
if (control.getActionHandler() != null) {
control.getActionHandler().run();
if (getSkinnable().getActionHandler() != null) {
getSkinnable().getActionHandler().run();
}
});
getChildren().setAll(root);
}
protected double calcHeight() {
var headerHeight = headerBox.getSpacing()
+ headerBox.getInsets().getTop()
+ headerBox.getInsets().getBottom()
+ titleLbl.getBoundsInLocal().getHeight()
+ (subTitleLbl.isManaged() ? subTitleLbl.getBoundsInLocal().getHeight() : 0);
return Math.max(Math.max(graphicSlot.getHeight(), actionSlot.getHeight()), headerHeight)
+ root.getPadding().getTop()
+ root.getPadding().getBottom();
}
@Override
protected double computeMinHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
// change the control height when label changed its height due to text wrapping,
// no other way to do that, all JavaFX containers completely ignore _the actual_
// height of its children
return calcHeight();
}
@Override
public void dispose() {
unregisterChangeListeners(getSkinnable().titleProperty());
unregisterChangeListeners(getSkinnable().subTitleProperty());
getSkinnable().graphicProperty().removeListener(graphicSlotListener);
getSkinnable().actionProperty().removeListener(actionSlotListener);
unregisterChangeListeners(getSkinnable().actionHandlerProperty());

@ -0,0 +1,144 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import atlantafx.base.util.BBCodeParser;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
public abstract class TileSkinBase<T extends TileBase> extends SkinBase<T> {
protected static final PseudoClass HAS_GRAPHIC = PseudoClass.getPseudoClass("has-graphic");
protected static final PseudoClass HAS_TITLE = PseudoClass.getPseudoClass("has-title");
protected static final PseudoClass HAS_DESCRIPTION = PseudoClass.getPseudoClass("has-description");
protected static final PseudoClass HAS_ACTION = PseudoClass.getPseudoClass("has-action");
protected final HBox root = new HBox();
protected final StackPane graphicSlot;
protected final ChangeListener<Node> graphicSlotListener;
protected final VBox headerBox;
protected final Label titleLbl;
protected final TextFlow descriptionText;
protected final StackPane actionSlot;
protected final ChangeListener<Node> actionSlotListener;
public TileSkinBase(T control) {
super(control);
graphicSlot = new StackPane();
graphicSlot.getStyleClass().add("graphic");
graphicSlotListener = new SlotListener(
graphicSlot, (n, active) -> getSkinnable().pseudoClassStateChanged(HAS_GRAPHIC, active)
);
control.graphicProperty().addListener(graphicSlotListener);
graphicSlotListener.changed(control.graphicProperty(), null, control.getGraphic());
titleLbl = new Label(control.getTitle());
titleLbl.getStyleClass().add("title");
titleLbl.setVisible(control.getTitle() != null);
titleLbl.setManaged(control.getTitle() != null);
descriptionText = new TextFlow();
descriptionText.getStyleClass().add("description");
descriptionText.setVisible(control.getDescription() != null);
descriptionText.setManaged(control.getDescription() != null);
setDescriptionText();
headerBox = new VBox(titleLbl, descriptionText);
headerBox.setFillWidth(true);
headerBox.getStyleClass().add("header");
HBox.setHgrow(headerBox, Priority.ALWAYS);
headerBox.setMinHeight(Region.USE_COMPUTED_SIZE);
headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE);
headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE);
control.pseudoClassStateChanged(HAS_TITLE, control.getTitle() != null);
registerChangeListener(control.titleProperty(), o -> {
var value = getSkinnable().getDescription();
titleLbl.setText(value);
titleLbl.setVisible(value != null);
titleLbl.setManaged(value != null);
getSkinnable().pseudoClassStateChanged(HAS_TITLE, value != null);
});
control.pseudoClassStateChanged(HAS_DESCRIPTION, control.getDescription() != null);
registerChangeListener(control.descriptionProperty(), o -> {
var value = getSkinnable().getDescription();
setDescriptionText();
descriptionText.setVisible(value != null);
descriptionText.setManaged(value != null);
getSkinnable().pseudoClassStateChanged(HAS_DESCRIPTION, value != null);
});
actionSlot = new StackPane();
actionSlot.getStyleClass().add("action");
actionSlotListener = new SlotListener(
actionSlot, (n, active) -> getSkinnable().pseudoClassStateChanged(HAS_ACTION, active)
);
// use pref size for slots, or they will be resized
// to the bare minimum due to Priority.ALWAYS
graphicSlot.setMinWidth(Region.USE_PREF_SIZE);
actionSlot.setMinWidth(Region.USE_PREF_SIZE);
// label text wrapping inside VBox won't work without this
descriptionText.setMaxWidth(Region.USE_PREF_SIZE);
descriptionText.setMinHeight(Region.USE_PREF_SIZE);
// do not resize children or container won't restore
// to its original size after expanding
root.setFillHeight(false);
root.getChildren().setAll(graphicSlot, headerBox, actionSlot);
root.getStyleClass().add("container");
getChildren().setAll(root);
}
protected void setDescriptionText() {
if (!descriptionText.getChildren().isEmpty()) {
descriptionText.getChildren().clear();
}
if (getSkinnable().getDescription() != null && !getSkinnable().getDescription().isBlank()) {
BBCodeParser.createLayout(getSkinnable().getDescription(), descriptionText);
}
}
protected double calcHeight() {
var headerHeight = headerBox.getSpacing()
+ headerBox.getInsets().getTop()
+ headerBox.getInsets().getBottom()
+ titleLbl.getBoundsInLocal().getHeight()
+ (descriptionText.isManaged() ? descriptionText.getBoundsInLocal().getHeight() : 0);
return Math.max(Math.max(graphicSlot.getHeight(), actionSlot.getHeight()), headerHeight)
+ root.getPadding().getTop()
+ root.getPadding().getBottom();
}
@Override
protected double computeMinHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
// change the control height when label changed its height due to text wrapping,
// no other way to do that, all JavaFX containers completely ignore _the actual_
// height of its children
return calcHeight();
}
@Override
public void dispose() {
unregisterChangeListeners(getSkinnable().titleProperty());
unregisterChangeListeners(getSkinnable().descriptionProperty());
getSkinnable().graphicProperty().removeListener(graphicSlotListener);
super.dispose();
}
}

@ -22,4 +22,8 @@ public final class BrowseEvent extends Event {
+ "uri=" + uri
+ "} " + super.toString();
}
public static void fire(String url) {
Event.publish(new BrowseEvent(URI.create(url)));
}
}

@ -37,4 +37,8 @@ public abstract class Event {
+ "id=" + id
+ '}';
}
public static <E extends Event> void publish(E event) {
DefaultEventBus.getInstance().publish(event);
}
}

@ -148,7 +148,7 @@ public class CardPage extends OutlinePage {
card1.setMaxWidth(250);
card1.setHeader(new Tile(
"This is a title",
"This is a subtitle"
"This is a description"
));
card1.setBody(new Label("This is content"));
@ -158,7 +158,7 @@ public class CardPage extends OutlinePage {
card2.setMaxWidth(250);
card2.setHeader(new Tile(
"This is a title",
"This is a subtitle"
"This is a description"
));
card2.setBody(new Label("This is content"));
//snippet_2:end

@ -1,20 +1,20 @@
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.base.controls.Message;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import java.net.URI;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.control.Hyperlink;
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;
@ -38,27 +38,60 @@ public class MessagePage extends OutlinePage {
addPageHeader();
addFormattedText("""
The [i]Message[/i] is a component for displaying notifications or alerts \
and is specifically designed to grab the users attention. It is \
based on the [i]Tile[/i] layout and shares its structure."""
The [i]Message[/i] is a component for displaying an important text or \
alerts and is specifically designed to grab the users attention. It is \
based on the [i]Tile[/i] layout and shares its structure, except it doesn't \
provide the action slot."""
);
addSection("Usage", usageExample());
addSection("No Title", noTitleExample());
addSection("Intent", intentExample());
addSection("Incomplete Header", incompleteHeaderExample());
addSection("Interactive", interactiveExample());
addSection("Banner", bannerExample());
addSection("Closeable", closeableExample());
}
private Node usageExample() {
// won't work inside the snippet, because code
// snippet use BBCode parse as well
var url = "https://wikipedia.org/wiki/The_Elder_Scrolls_III:_Morrowind";
var quote = FAKER.elderScrolls().quote()
+ " \n[url=" + url + "]Learn more[/url]";
//snippet_1:start
var msg = new Message(
"Quote",
quote,
new FontIcon(Material2OutlinedAL.CHAT_BUBBLE_OUTLINE)
);
msg.addEventFilter(ActionEvent.ACTION, e -> {
if (e.getTarget() instanceof Hyperlink link) {
BrowseEvent.fire((String) link.getUserData());
}
e.consume();
});
//snippet_1:end
var box = new VBox(msg);
var description = BBCodeParser.createFormattedText("""
[i]Message[/i] does not have any mandatory properties. It supports text \
wrapping and [i]BBCode[/i] formatted text, but only in the description field."""
);
return new ExampleBox(box, new Snippet(getClass(), 1), description);
}
private Node intentExample() {
//snippet_5:start
var info = new Message(
"Info",
FAKER.lorem().sentence(5),
FAKER.lorem().sentence(10),
new FontIcon(Material2OutlinedAL.HELP_OUTLINE)
);
info.getStyleClass().add(Styles.ACCENT);
var success = new Message(
"Success",
FAKER.lorem().sentence(10),
FAKER.lorem().sentence(15),
new FontIcon(Material2OutlinedAL.CHECK_CIRCLE_OUTLINE)
);
success.getStyleClass().add(Styles.SUCCESS);
@ -76,45 +109,39 @@ public class MessagePage extends OutlinePage {
new FontIcon(Material2OutlinedAL.ERROR_OUTLINE)
);
danger.getStyleClass().add(Styles.DANGER);
//snippet_1:end
//snippet_5: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."""
The [i]Message[/i] offers four severity levels that set a distinctive color. \
To change the [i]Message[/i] intent, use the corresponding style class modifier."""
);
return new ExampleBox(box, new Snippet(getClass(), 1), description);
return new ExampleBox(box, new Snippet(getClass(), 5), description);
}
private Node noTitleExample() {
private Node incompleteHeaderExample() {
//snippet_2:start
var info1 = new Message(
null,
FAKER.lorem().sentence(5),
null,
new FontIcon(Material2OutlinedAL.HELP_OUTLINE)
);
info1.getStyleClass().add(Styles.ACCENT);
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);
info2.getStyleClass().add(Styles.ACCENT);
//snippet_2:end
var box = new VBox(VGAP_10, info1, info2, info3);
var box = new VBox(VGAP_10, info1, info2);
var description = BBCodeParser.createFormattedText("""
Unlike the [i]Tile[/i], the message title is optional. This example \
demonstrates various messages without a title."""
Both the title and description are completely optional, but one them has to be \
specified in any case. Note that the styling changes depending on whether the [i]Message[/i] \
has only a title, only a description, or both."""
);
return new ExampleBox(box, new Snippet(getClass(), 2), description);
}
@ -128,57 +155,72 @@ public class MessagePage extends OutlinePage {
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."""
A [i]Message[/i] can be made interactive by setting an action handler. \
This allows to call any arbitrary action when the user clicks inside \
the message container. For example, you could show an extended dialog or \
trigger a notification panel to appear.."""
);
return new ExampleBox(box, new Snippet(getClass(), 3), description);
}
private Node bannerExample() {
private Node closeableExample() {
//snippet_4:start
final var msg = new Message(
null,
FAKER.lorem().sentence(10)
var regular = new Message(
"Regular",
FAKER.lorem().sentence(5),
new FontIcon(Material2OutlinedAL.CHAT_BUBBLE_OUTLINE)
);
msg.getStyleClass().addAll(
Styles.DANGER, Tweaks.EDGE_TO_EDGE
regular.setOnClose(e -> Animations.flash(regular).playFromStart());
var info = new Message(
"Info",
FAKER.lorem().sentence(10),
new FontIcon(Material2OutlinedAL.HELP_OUTLINE)
);
info.getStyleClass().add(Styles.ACCENT);
info.setOnClose(e -> Animations.flash(info).playFromStart());
var closeBtn = new Button("Close");
closeBtn.getStyleClass().addAll(Styles.DANGER);
msg.setAction(closeBtn);
var success = new Message(
"Success",
FAKER.lorem().sentence(15),
new FontIcon(Material2OutlinedAL.CHECK_CIRCLE_OUTLINE)
);
success.getStyleClass().add(Styles.SUCCESS);
success.setOnClose(e -> Animations.flash(success).playFromStart());
var showBannerBtn = new Button("Show banner");
showBannerBtn.setOnAction(e1 -> {
var parent = (BorderPane) getScene().lookup("#main");
var warning = new Message(
"Warning",
FAKER.lorem().sentence(20),
new FontIcon(Material2OutlinedMZ.OUTLINED_FLAG)
);
warning.getStyleClass().add(Styles.WARNING);
warning.setOnClose(e -> Animations.flash(warning).playFromStart());
parent.setTop(new VBox(msg));
closeBtn.setOnAction(e2 -> parent.setTop(null));
msg.setOpacity(0);
Animations.fadeInDown(msg, Duration.millis(350))
.playFromStart();
});
var danger = new Message(
"Danger",
FAKER.lorem().sentence(25),
new FontIcon(Material2OutlinedAL.ERROR_OUTLINE)
);
danger.getStyleClass().add(Styles.DANGER);
danger.setOnClose(e -> Animations.flash(danger).playFromStart());
//snippet_4:end
var box = new VBox(showBannerBtn);
var box = new VBox(VGAP_10, regular, info, success, warning, danger);
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."""
You can make the [i]Message[/i] closeable by setting an appropriate message handler. \
If the handler is set, the close button will appear in the top right corner of the \
[i]Message[/i]. This handler should provide some logic for removing the [i]Message[/i] \
from its parent container as no default implementation is provided."""
);
box.setPadding(new Insets(0, 0, 5, 0));
var example = new ExampleBox(box, new Snippet(getClass(), 4), description);
example.setAllowDisable(false);
return example;
return new ExampleBox(box, new Snippet(getClass(), 4), description);
}
}

@ -1,23 +1,27 @@
package atlantafx.sampler.page.components;
import atlantafx.base.controls.PasswordTextField;
import atlantafx.base.controls.Tile;
import atlantafx.base.controls.ToggleSwitch;
import atlantafx.base.theme.Styles;
import atlantafx.base.util.Animations;
import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.Resources;
import atlantafx.base.controls.Tile;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import java.net.URI;
import java.util.function.BiFunction;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.control.Spinner;
@ -28,6 +32,7 @@ import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import org.kordamp.ikonli.material2.Material2OutlinedAL;
public class TilePage extends OutlinePage {
@ -51,12 +56,13 @@ public class TilePage extends OutlinePage {
addFormattedText("""
The Tile is a versatile container that can used in various contexts \
such as dialog headers, list items, and cards. It can contain a graphic, \
a title, subtitle, and optional actions."""
a title, description, and optional actions."""
);
addNode(skeleton());
addSection("Usage", usageExample());
addSection("Interactive", interactiveExample());
addSection("Stacking", stackingExample());
addSection("Incomplete Header", incompleteHeaderExample());
}
private Node skeleton() {
@ -79,7 +85,7 @@ public class TilePage extends OutlinePage {
grid.setMaxWidth(600);
grid.add(cellBuilder.apply("Graphic", Pos.CENTER), 0, 0, 1, GridPane.REMAINING);
grid.add(cellBuilder.apply("Title", Pos.CENTER_LEFT), 1, 0, 1, 1);
grid.add(cellBuilder.apply("Subtitle", Pos.CENTER_LEFT), 1, 1, 1, 1);
grid.add(cellBuilder.apply("Description", Pos.CENTER_LEFT), 1, 1, 1, 1);
grid.add(cellBuilder.apply("Action", Pos.CENTER), 2, 0, 1, GridPane.REMAINING);
grid.getColumnConstraints().setAll(
new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true),
@ -91,16 +97,25 @@ public class TilePage extends OutlinePage {
}
private Node usageExample() {
// won't work inside the snippet, because code
// snippet use BBCode parse as well
var url = "https://wikipedia.org/wiki/The_Elder_Scrolls_III:_Morrowind";
var quote = FAKER.elderScrolls().quote()
+ " \n[url=" + url + "]Learn more[/url]";
//snippet_1:start
var tile1 = new Tile(
"Title",
FAKER.lorem().sentence(15)
"Multiline Description",
FAKER.lorem().sentence(50)
);
var tile2 = new Tile(
FAKER.name().fullName(),
FAKER.elderScrolls().quote()
);
var tile2 = new Tile(FAKER.name().fullName(), quote);
tile2.addEventFilter(ActionEvent.ACTION, e -> {
if (e.getTarget() instanceof Hyperlink link) {
BrowseEvent.fire((String) link.getUserData());
}
e.consume();
});
var img = new ImageView(new Image(
Resources.getResourceAsStream("images/avatars/avatar1.png")
@ -121,9 +136,8 @@ public class TilePage extends OutlinePage {
tile3
);
var description = BBCodeParser.createFormattedText("""
[i]Tile[/i] does not have any mandatory properties, but you will not want \
to use it without a title. Additionally, note that only the subtitle supports \
text wrapping."""
[i]Tile[/i] does not have any mandatory properties. It supports text \
wrapping and [i]BBCode[/i] formatted text, but only in the description field."""
);
return new ExampleBox(box, new Snippet(getClass(), 1), description);
@ -133,7 +147,7 @@ public class TilePage extends OutlinePage {
//snippet_2:start
var tile = new Tile(
"Password",
"Please enter your authentication password to unlock the content"
"Please enter your authentication password"
);
var tf = new PasswordTextField(null);
@ -197,4 +211,28 @@ public class TilePage extends OutlinePage {
return new ExampleBox(box, new Snippet(getClass(), 3), description);
}
private Node incompleteHeaderExample() {
//snippet_4:start
var tile1 = new Tile("Go to the next screen", null);
tile1.setAction(new FontIcon(Material2AL.ARROW_RIGHT));
tile1.setActionHandler(() ->
Animations.wobble(tile1).playFromStart()
);
var tile2 = new Tile(
null, FAKER.friends().quote(),
new FontIcon(Material2OutlinedAL.FORMAT_QUOTE)
);
//snippet_4:end
var box = new VBox(tile1, new Separator(), tile2);
var description = BBCodeParser.createFormattedText("""
Both the title and description are completely optional, but one them has to be \
specified in any case. Note that the styling changes depending on whether the [i]Tile[/i] \
has only a title, only a description, or both.\""""
);
return new ExampleBox(box, new Snippet(getClass(), 4), description);
}
}

@ -32,7 +32,7 @@
<PasswordTextField layoutX="14.0" layoutY="546.0" prefHeight="36.0" prefWidth="264.0" text="password" />
<Label layoutX="14.0" layoutY="517.0" style="-fx-text-fill: green;" text="PasswordTextField" />
<RingProgressIndicator layoutX="326.0" layoutY="25.0" progress="0.35" />
<Tile layoutX="320.0" layoutY="209.0" prefHeight="73.0" prefWidth="483.0" subTitle="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla fermentum, quam eu pretium euismod, ipsum mauris interdum massa, at scelerisque nulla augue a nunc. Vivamus vehicula rhoncus est, ut placerat nulla pellentesque vel. Duis ac mattis sapien. " title="Title" />
<Tile layoutX="320.0" layoutY="209.0" prefHeight="73.0" prefWidth="483.0" description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla fermentum, quam eu pretium euismod, ipsum mauris interdum massa, at scelerisque nulla augue a nunc. Vivamus vehicula rhoncus est, ut placerat nulla pellentesque vel. Duis ac mattis sapien. " title="Title" />
<InputGroup layoutX="526.0" layoutY="57.0">
<children>
<ToggleButton mnemonicParsing="false" text="Toggle1" />

@ -583,7 +583,7 @@
<content>
<AnchorPane minHeight="0.0" minWidth="0.0" prefHeight="180.0" prefWidth="200.0">
<children>
<Tile layoutX="-56.0" layoutY="44.0" subTitle="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sagittis vehicula elit, ac dictum metus bibendum id. Integer elit purus, varius ac eros eu, convallis ultricies tellus." title="Title" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
<Tile layoutX="-56.0" layoutY="44.0" description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sagittis vehicula elit, ac dictum metus bibendum id. Integer elit purus, varius ac eros eu, convallis ultricies tellus." title="Title" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
</children>
</AnchorPane>
</content>

@ -1,111 +1,168 @@
// SPDX-License-Identifier: MIT
@use "../settings/config" as cfg;
@use "../settings/icons";
$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: -color-bg-subtle !default;
$color-fg-primary: -color-fg-default !default;
$color-fg-secondary: -color-fg-default !default;
$color-border: -color-border-muted !default;
$color-border-hover: -color-border-default !default;
$color-button-bg-hover: -color-bg-default !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-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-button-accent-bg-hover: -color-accent-muted !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-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-button-success-bg-hover: -color-success-muted !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;
$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-button-warning-bg-hover: -color-warning-muted !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;
$color-button-danger-bg-hover: -color-danger-muted !default;
$close-button-radius: 100px !default;
$close-button-padding: 0.6em !default;
$close-button-icon-size: 0.3em !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;
-color-message-bg: $color-bg;
-color-message-fg-primary: $color-fg-primary;
-color-message-fg-secondary: $color-fg-secondary;
-color-message-border: $color-border;
-color-message-button-hover: $color-button-bg-hover;
-color-message-border-interactive: $color-border-hover;
&.accent {
-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-button-hover: $color-button-accent-bg-hover;
-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-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: $color-border-success;
-color-message-button-hover: $color-button-success-bg-hover;
-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-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: $color-border-warning;
-color-message-button-hover: $color-button-warning-bg-hover;
-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-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: $color-border-danger;
-color-message-button-hover: $color-button-danger-bg-hover;
-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;
&.tile-base {
>.container {
-fx-background-color: -color-message-bg;
-fx-alignment: CENTER_LEFT;
-fx-border-color: -color-message-border;
-fx-border-width: cfg.$border-width;
-fx-border-radius: cfg.$border-radius;
>.header {
>.title {
-fx-text-fill: -color-message-fg-secondary;
}
>.description {
Text {
-fx-fill: -color-message-fg-secondary;
}
}
}
#{cfg.$font-icon-selector} {
-fx-icon-color: -color-message-fg-primary;
-fx-fill: -color-message-fg-primary;
-fx-icon-size: cfg.$icon-size-larger;
}
}
&: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;
>.container {
-fx-background-color: -color-message-bg;
-fx-border-color: -color-message-border-interactive;
-fx-cursor: hand;
}
}
>.action {
-fx-alignment: TOP_LEFT;
}
&:has-title:has-description {
>.container>.header {
>.title {
-fx-text-fill: -color-message-fg-primary;
}
#{cfg.$font-icon-selector} {
-fx-icon-color: -color-message-fg-primary;
-fx-fill: -color-message-fg-primary;
-fx-icon-size: cfg.$icon-size-larger;
>.description {
Text {
-fx-fill: -color-message-fg-secondary;
}
}
}
}
}
&.edge-to-edge > .tile {
-fx-border-width: 0;
-fx-border-radius: 0;
// CLOSE BUTTON
>.close-button {
-fx-background-radius: $close-button-radius;
-fx-padding: $close-button-padding;
>.icon {
@include icons.get("close", true);
-fx-background-color: -color-message-fg-primary;
-fx-padding: $close-button-icon-size;
}
&:hover {
-fx-background-color: -color-message-button-hover;
}
}
&:closeable {
>.container {
>.header {
>.title,
>.description {
-fx-padding: 0 ($close-button-padding * 3) 0 0;
}
}
}
}
}

@ -3,46 +3,50 @@
@use "../settings/config" as cfg;
$color-interactive: -color-bg-subtle !default;
$title-font-size: 1.05em !default; // semibold would be much better, but it's JavaFX after all
.tile {
-fx-padding: 0.75em 1em 0.75em 1em;
-fx-alignment: CENTER_LEFT;
-fx-background-radius: cfg.$border-radius;
.tile-base {
&:hover:interactive {
-fx-background-color: $color-interactive;
-fx-cursor: hand;
}
>.graphic {
&:filled {
-fx-padding: 0 1em 0 0;
}
}
>.header {
>.container {
-fx-padding: 1em;
-fx-alignment: CENTER_LEFT;
-fx-padding: 0;
-fx-background-radius: cfg.$border-radius;
-fx-spacing: 1em;
>.title {
-fx-font-size: cfg.$font-title-4;
// >.graphic {}
>.header {
-fx-alignment: CENTER_LEFT;
-fx-padding: 0;
>.title {
-fx-font-size: $title-font-size;
}
}
>.subtitle {
-fx-text-fill: -color-fg-muted;
}
// >.action {}
}
&:with-title:with-subtitle {
>.header {
&:has-title:has-description {
>.container >.header {
-fx-spacing: 0.5em;
-fx-alignment: TOP_LEFT;
}
}
>.action {
&:filled {
-fx-padding: 0 0 0 1em;
>.description {
Text {
-fx-fill: -color-fg-muted;
}
}
}
}
}
.tile {
&:hover:interactive {
>.container {
-fx-background-color: $color-interactive;
-fx-cursor: hand;
}
}
}