diff --git a/base/src/main/java/atlantafx/base/controls/SlotListener.java b/base/src/main/java/atlantafx/base/controls/SlotListener.java new file mode 100644 index 0000000..f438446 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/SlotListener.java @@ -0,0 +1,39 @@ +/* SPDX-License-Identifier: MIT */ + +package atlantafx.base.controls; + +import java.util.Objects; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.css.PseudoClass; +import javafx.scene.Node; +import javafx.scene.layout.Pane; + +final class SlotListener implements ChangeListener { + + private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled"); + + private final Pane slot; + + public SlotListener(Node slot) { + Objects.requireNonNull(slot, "Slot cannot be null."); + + if (slot instanceof Pane pane) { + this.slot = pane; + } else { + throw new IllegalArgumentException("Invalid slot type. Pane is required."); + } + } + + @Override + public void changed(ObservableValue obs, Node old, Node val) { + if (val != null) { + slot.getChildren().setAll(val); + } else { + slot.getChildren().clear(); + } + slot.setVisible(val != null); + slot.setManaged(val != null); + slot.pseudoClassStateChanged(FILLED, val != null); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/Tile.java b/base/src/main/java/atlantafx/base/controls/Tile.java new file mode 100644 index 0000000..0330d52 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/Tile.java @@ -0,0 +1,138 @@ +/* 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 javafx.scene.control.Skin; + +/** + * 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. + */ +public class Tile extends Control { + + public Tile() { + this(null, null, null); + } + + public Tile(@NamedArg("title") String title, + @NamedArg("subtitle") String subtitle) { + this(title, subtitle, null); + } + + public Tile(@NamedArg("title") String title, + @NamedArg("subtitle") String subtitle, + @NamedArg("graphic") Node graphic) { + super(); + setTitle(title); + setSubTitle(subtitle); + setGraphic(graphic); + } + + @Override + protected Skin createDefaultSkin() { + return new TileSkin(this); + } + + /////////////////////////////////////////////////////////////////////////// + // Properties // + /////////////////////////////////////////////////////////////////////////// + + /** + * The property representing the tile’s graphic node. It is commonly used + * to add images or icons that are associated with the tile. + */ + private final ObjectProperty graphic = new SimpleObjectProperty<>(this, "graphic"); + + public Node getGraphic() { + return graphic.get(); + } + + public ObjectProperty graphicProperty() { + return graphic; + } + + public void setGraphic(Node graphic) { + this.graphic.set(graphic); + } + + /** + * The property representing the tile’s 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 tile’s 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 tile’s action node. It is commonly used + * to place an action controls that are associated with the tile. + */ + private final ObjectProperty action = new SimpleObjectProperty<>(this, "action"); + + public Node getAction() { + return action.get(); + } + + public ObjectProperty actionProperty() { + return action; + } + + public void setAction(Node action) { + this.action.set(action); + } + + /** + * The property representing the tile’s action handler. Setting an action handler + * makes the tile interactive or clickable. When a user clicks on the interactive + * tile, the specified action handler will be called. + */ + private final ObjectProperty actionHandler = new SimpleObjectProperty<>(this, "actionHandler"); + + public Runnable getActionHandler() { + return actionHandler.get(); + } + + public ObjectProperty actionHandlerProperty() { + return actionHandler; + } + + public void setActionHandler(Runnable actionHandler) { + this.actionHandler.set(actionHandler); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/TileSkin.java b/base/src/main/java/atlantafx/base/controls/TileSkin.java new file mode 100644 index 0000000..f1593cf --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/TileSkin.java @@ -0,0 +1,135 @@ +/* SPDX-License-Identifier: MIT */ + +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 { + + private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled"); + + protected final HBox root = new HBox(); + protected final StackPane graphicSlot; + protected final ChangeListener graphicSlotListener; + protected final VBox headerBox; + protected final Label titleLbl; + protected final Label subTitleLbl; + protected final StackPane actionSlot; + protected final ChangeListener actionSlotListener; + + 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.textProperty().bind(control.titleProperty()); + + 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); + + 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); + }); + + 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); + registerChangeListener( + control.actionHandlerProperty(), + o -> root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null) + ); + root.setOnMouseClicked(e -> { + if (control.getActionHandler() != null) { + control.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() { + titleLbl.textProperty().unbind(); + unregisterChangeListeners(getSkinnable().subTitleProperty()); + getSkinnable().graphicProperty().removeListener(graphicSlotListener); + getSkinnable().actionProperty().removeListener(actionSlotListener); + unregisterChangeListeners(getSkinnable().actionHandlerProperty()); + + super.dispose(); + } +} diff --git a/base/src/main/java/atlantafx/base/theme/Styles.java b/base/src/main/java/atlantafx/base/theme/Styles.java index a9a93aa..1884742 100644 --- a/base/src/main/java/atlantafx/base/theme/Styles.java +++ b/base/src/main/java/atlantafx/base/theme/Styles.java @@ -26,11 +26,6 @@ public final class Styles { public static final String WARNING = "warning"; public static final String DANGER = "danger"; - public static final PseudoClass STATE_ACCENT = PseudoClass.getPseudoClass(ACCENT); - public static final PseudoClass STATE_SUCCESS = PseudoClass.getPseudoClass(SUCCESS); - public static final PseudoClass STATE_WARNING = PseudoClass.getPseudoClass(WARNING); - public static final PseudoClass STATE_DANGER = PseudoClass.getPseudoClass(DANGER); - // Controls public static final String TEXT = "text"; @@ -90,6 +85,14 @@ public final class Styles { public static final String TEXT_MUTED = "text-muted"; public static final String TEXT_SUBTLE = "text-subtle"; + // Pseudo-classes + + public static final PseudoClass STATE_ACCENT = PseudoClass.getPseudoClass(ACCENT); + public static final PseudoClass STATE_SUCCESS = PseudoClass.getPseudoClass(SUCCESS); + public static final PseudoClass STATE_WARNING = PseudoClass.getPseudoClass(WARNING); + public static final PseudoClass STATE_DANGER = PseudoClass.getPseudoClass(DANGER); + public static final PseudoClass STATE_INTERACTIVE = PseudoClass.getPseudoClass(INTERACTIVE); + private Styles() { // Default constructor } diff --git a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java index 7a94598..36b267b 100644 --- a/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java +++ b/sampler/src/main/java/atlantafx/sampler/layout/MainModel.java @@ -40,6 +40,7 @@ import atlantafx.sampler.page.components.TabPanePage; import atlantafx.sampler.page.components.TableViewPage; import atlantafx.sampler.page.components.TextAreaPage; import atlantafx.sampler.page.components.TextFieldPage; +import atlantafx.sampler.page.components.TilePage; import atlantafx.sampler.page.components.TitledPanePage; import atlantafx.sampler.page.components.ToggleButtonPage; import atlantafx.sampler.page.components.ToggleSwitchPage; @@ -164,6 +165,7 @@ public class MainModel { NAV_TREE.get(SeparatorPage.class), NAV_TREE.get(SplitPanePage.class), NAV_TREE.get(PopoverPage.class), + NAV_TREE.get(TilePage.class), NAV_TREE.get(TitledPanePage.class), NAV_TREE.get(ToolBarPage.class) ); @@ -297,6 +299,7 @@ public class MainModel { map.put(SliderPage.class, NavTree.Item.page(SliderPage.NAME, SliderPage.class)); map.put(SpinnerPage.class, NavTree.Item.page(SpinnerPage.NAME, SpinnerPage.class)); map.put(SplitPanePage.class, NavTree.Item.page(SplitPanePage.NAME, SplitPanePage.class)); + map.put(TilePage.class, NavTree.Item.page(TilePage.NAME, TilePage.class)); map.put(TableViewPage.class, NavTree.Item.page(TableViewPage.NAME, TableViewPage.class)); map.put(TabPanePage.class, NavTree.Item.page(TabPanePage.NAME, TabPanePage.class)); map.put(TextAreaPage.class, NavTree.Item.page(TextAreaPage.NAME, TextAreaPage.class)); diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java b/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java new file mode 100644 index 0000000..bbeedf4 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/components/TilePage.java @@ -0,0 +1,199 @@ +package atlantafx.sampler.page.components; + +import atlantafx.base.controls.PasswordTextField; +import atlantafx.base.controls.ToggleSwitch; +import atlantafx.base.theme.Styles; +import atlantafx.base.util.BBCodeParser; +import atlantafx.sampler.Resources; +import atlantafx.base.controls.Tile; +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.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.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Spinner; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.ColumnConstraints; +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.Material2OutlinedAL; + +public class TilePage extends OutlinePage { + + public static final String NAME = "Tile"; + + @Override + public String getName() { + return NAME; + } + + @Override + public URI getJavadocUri() { + return URI.create(String.format(AFX_JAVADOC_URI_TEMPLATE, "controls/" + getName())); + } + + public TilePage() { + super(); + + addPageHeader(); + 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.""" + ); + addNode(skeleton()); + addSection("Usage", usageExample()); + addSection("Interactive", interactiveExample()); + addSection("Stacking", stackingExample()); + } + + private Node skeleton() { + BiFunction cellBuilder = (s, pos) -> { + var cell = new VBox(new Label(s)); + cell.setPadding(new Insets(10)); + cell.setFillWidth(true); + cell.setAlignment(pos); + Styles.appendStyle(cell, "-fx-border-color", "-color-accent-muted"); + Styles.appendStyle(cell, "-fx-border-width", "5px"); + return cell; + }; + + var grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + 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("Action", Pos.CENTER), 2, 0, 1, GridPane.REMAINING); + grid.getColumnConstraints().setAll( + new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true), + new ColumnConstraints(-1, -1, -1, Priority.ALWAYS, HPos.CENTER, true), + new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true) + ); + + return grid; + } + + private Node usageExample() { + //snippet_1:start + var tile1 = new Tile( + "Title", + FAKER.lorem().sentence(15) + ); + + var tile2 = new Tile( + FAKER.name().fullName(), + FAKER.elderScrolls().quote() + ); + + var img = new ImageView(new Image( + Resources.getResourceAsStream( + "assets/fxml/blueprints/resources/avatar1.png" + ) + )); + img.setFitWidth(64); + img.setFitHeight(64); + tile2.setGraphic(img); + + var tile3 = new Tile("Photos", "Last updated: Jun 9, 2019"); + var btn = new Button("", new FontIcon(Material2OutlinedAL.DELETE)); + btn.getStyleClass().addAll(Styles.BUTTON_CIRCLE, Styles.FLAT); + tile3.setAction(btn); + //snippet_1:end + + var box = new VBox( + tile1, new Separator(), + tile2, new Separator(), + 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.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 1), description); + } + + private Node interactiveExample() { + //snippet_2:start + var tile = new Tile( + "Password", + "Please enter your authentication password to unlock the content" + ); + + var tf = new PasswordTextField(null); + tf.setPromptText("Click on the tile"); + tf.setPrefWidth(150); + + tile.setAction(tf); + tile.setActionHandler(tf::requestFocus); + //snippet_2:end + + var box = new VBox(tile); + var description = BBCodeParser.createFormattedText(""" + A [i]Tile[/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(), 2), description); + } + + private Node stackingExample() { + //snippet_3:start + var tile1 = new Tile( + "Content filtering", + "Set the content filtering level to restrict downloaded apps" + ); + var cmb = new ComboBox<>(FXCollections.observableArrayList( + "Everyone", "Low", "Medium", "High" + )); + cmb.getSelectionModel().selectLast(); + cmb.setPrefWidth(150); + tile1.setAction(cmb); + + var tile2 = new Tile( + "Password", + "Require password for purchase" + ); + var tgl2 = new ToggleSwitch(); + tile2.setAction(tgl2); + tile2.setActionHandler(() -> tgl2.setSelected(!tgl2.isSelected())); + + var tile3 = new Tile("Cache Size (Mb)", null); + var spinner = new Spinner<>(10, 100, 50); + spinner.setPrefWidth(150); + tile3.setAction(spinner); + + var tile4 = new Tile( + "Notifications", + "Notify me about updates to apps that I downloaded" + ); + var tgl3 = new ToggleSwitch(); + tile4.setAction(tgl3); + tile4.setActionHandler(() -> tgl3.setSelected(!tgl3.isSelected())); + + var box = new VBox(tile1, tile2, tile3, new Separator(), tile4); + //snippet_3:end + + var description = BBCodeParser.createFormattedText(""" + You can stack several [i]Tiles[/i] vertically. Optionally, use the [i]Separator[/i] \ + to split them into groups.""" + ); + + return new ExampleBox(box, new Snippet(getClass(), 3), description); + } +} diff --git a/styles/src/components/_index.scss b/styles/src/components/_index.scss index 30139cd..763cece 100755 --- a/styles/src/components/_index.scss +++ b/styles/src/components/_index.scss @@ -27,6 +27,7 @@ @use "split-pane"; @use "tab-pane"; @use "text-input"; +@use "tile"; @use "titled-pane"; @use "toggle-button"; @use "toggle-switch"; diff --git a/styles/src/components/_tile.scss b/styles/src/components/_tile.scss new file mode 100644 index 0000000..d5ce1b3 --- /dev/null +++ b/styles/src/components/_tile.scss @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +$color-interactive: -color-bg-subtle !default; + +@use "../settings/config" as cfg; + +.tile { + -fx-padding: 0.75em 1em 0.75em 1em; + -fx-alignment: CENTER_LEFT; + -fx-background-radius: cfg.$border-radius; + + &:hover:interactive { + -fx-background-color: $color-interactive; + -fx-cursor: hand; + } + + >.graphic { + &:filled { + -fx-padding: 0 1em 0 0; + } + } + + >.header { + -fx-alignment: CENTER_LEFT; + -fx-padding: 0; + + >.title { + -fx-font-size: cfg.$font-title-4; + } + + >.subtitle { + -fx-text-fill: -color-fg-muted; + } + + &:filled { + -fx-spacing: 5px; + -fx-alignment: TOP_LEFT; + } + } + + >.action { + &:filled { + -fx-padding: 0 0 0 1em; + } + } +}