diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index dfa79ec6d..e0eb7227e 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1874,6 +1874,211 @@ date_range_picker7 = ui.date_range_picker( ) ``` +###### ui.dialog + +Dialogs are windows containing contextual information, tasks, or workflows that appear over the user interface. Depending on the kind of Dialog, further interactions may be blocked until the Dialog is acknowledged. + +```py +import deephaven.ui as ui +ui.dialog( + *children: Any, + size: DialogSize | None = None, + is_dismissable: bool | None = None, + on_dismiss: Callable[[], None] | None = None, + **props: Any +) -> DialogElement +``` + +###### Content + +The content can be populated by providing the following components to your `dialog` as children: + +- `header` (optional) +- `heading` (title, required) +- `divider` (optional) +- `content` (body, required) +- `button_group` (optional) +- `footer` (optional) + +###### Parameters + +| Parameter | Type | Description | +| ---------------- | ---------------------------- | --------------------------------------------------------------------------------------- | +| `*children` | `Any` | The contents of the Dialog. | +| `size` | `DialogSize \| None` | The size of the Dialog. Either `S`, `M`, or `L` . Only applies to "modal" type Dialogs. | +| `is_dismissable` | `bool \| None` | Whether the Dialog is dismissable. | +| `on_dismiss` | `Callable[[], None] \| None` | Handler that is called when the 'x' button of a dismissable Dialog is clicked. | +| `**props` | `Any` | Any other [Dialog](https://react-spectrum.adobe.com/react-spectrum/Dialog.html) prop | + +```py +from deephaven import ui + +# Open and closed using flag (controlled) +@ui.component +def open_close_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Open dialog", on_press=set_open.on), + ui.dialog(ui.heading("Dialog"), ui.content("Close using the button."), ui.button_group(ui.button("close", on_press=set_open.off))), + is_open=is_open + ) + +my_open_close_example = open_close_example() + +# Dismissable (uncontrolled) +my_dismissable = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Dismiss using the X button."), + is_dismissable=True, + ), + ) + +# A small dialog +my_small = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog(ui.heading("Dialog"), ui.content("Dismiss using the X button."), is_dismissable=True, size="S"), + ) + +from deephaven import ui + +# Dismissable callback (controlled) +@ui.component +def dismissable_callback(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Open dialog", on_press=set_open.on), + ui.dialog(ui.heading("Dialog"), + ui.content("Dismiss using the X button."), + is_dismissable=True, + on_dismiss=set_open.off + ), + is_open=is_open + ) + +my_dismissable_callback = dismissable_callback() +``` + +###### ui.dialog_trigger + +`dialog_trigger` serves as a wrapper around a `dialog` and its associated trigger, linking the `dialog's` open state with the trigger's press state. Additionally, it allows you to customize the type and positioning of the `dialog`. + +```py +import deephaven.ui as ui +ui.dialog_trigger( + *children: Element, + type: DialogTriggerType | None = "modal", + placement: Placement | None = "bottom", + is_open: bool | None = None, + default_open: bool | None = None, + container_padding: float | None = None, + offset: float | None = None, + cross_offset: float | None = None, + should_flip: bool | None = None, + hide_arrow: bool | None = None, + is_dismissable: bool | None = None, + is_keyboard_dismiss_disabled: bool | None = None, + on_open_change: Callable[[bool], None] | None = None, + **props: Any +) -> DialogTriggerElement +``` + +###### Dialog types + +By providing a `type` prop, you can specify the type of `dialog` that is rendered by your `dialog_trigger`. + +- `modal` +- `popover` +- `tray` +- `fullscreen` +- `fullscreenTakeover` + +###### Parameters + +| Parameter | Type | Description | +| ------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `*children` | `Element` | The Dialog and its trigger element. | +| `type` | `DialogTriggerType \| None` | The type of Dialog that should be rendered. | +| `placement` | `Placement \| None` | The placement of the popover relative to the action button. | +| `is_open` | `bool \| None` | Whether the popover is open by default (controlled). | +| `default_open` | `bool \| None` | Whether the popover is open by default (uncontrolled). | +| `container_padding` | `float \| None` | The placement padding that should be applied between the element and its surrounding container. | +| `offset` | `float \| None` | The additional offset applied along the main axis between the element and its anchor element. | +| `cross_offset` | `float \| None` | The additional offset applied along the cross axis between the element and its anchor element. | +| `should_flip` | `bool \| None` | Whether the element should flip its orientation when there is insufficient room for it to render completely. | +| `hide_arrow` | `bool \| None` | Whether a popover type Dialog's arrow should be hidden. | +| `is_dismissable` | `bool \| None` | Whether a modal type Dialog should be dismissable. | +| `is_keyboard_dismiss_disabled` | `bool \| None` | Whether pressing the escape key to close the dialog should be disabled. | +| `on_open_change` | `Callable[[bool], None] \| None` | Handler that is called when the overlay's open state changes. | +| `**props` | `Any` | Any other [Dialog](https://react-spectrum.adobe.com/react-spectrum/Dialog.html) prop | + +```py +from deephaven import ui + +# Open and closed using flag (controlled) +@ui.component +def open_close_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Open dialog", on_press=set_open.on), + ui.dialog(ui.heading("Dialog"), ui.content("Close using the button."), ui.button_group(ui.button("close", on_press=set_open.off))), + is_open=is_open + ) + +my_open_close_example = open_close_example() + +# Dismissable (uncontrolled) +my_dismissable = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Dismiss using the X button."), + ), + is_dismissable=True, + ) + +# popover +my_popover = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Popover."), + ), + type="popover" + ) + +# tray +my_tray = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Tray."), + ), + type="tray" + ) + +# fullscreen +my_fullscreen = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Fullscreen."), + ), + type="fullscreen" + ) + +# takeover +my_takeover = ui.dialog_trigger( + ui.action_button("Open dialog",), + ui.dialog( + ui.heading("Dialog"), + ui.content("Fullscreen takeover."), + ), + type="fullscreenTakeover" + ) +``` + ##### ui.combo_box A combo_box that can be used to search or select from a list. Children should be one of five types: diff --git a/plugins/ui/docs/components/dialog.md b/plugins/ui/docs/components/dialog.md new file mode 100644 index 000000000..ccc7c210d --- /dev/null +++ b/plugins/ui/docs/components/dialog.md @@ -0,0 +1,322 @@ +# Dialog + +Dialogs are windows containing contextual information, tasks, or workflows that appear over the user interface. Depending on the kind of dialog, further interactions may be blocked until the dialog is acknowledged. + +## Example + +```python +from deephaven import ui + + +@ui.component +def dialog_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Check connectivity", on_press=set_open.on), + ui.dialog( + ui.heading("Internet Speed Test"), + ui.content("Start speed test?"), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Confirm", variant="accent", on_press=set_open.off), + ), + ), + is_open=is_open, + ) + + +my_dialog_example = dialog_example() +``` + +## Content + +The content can be populated by providing the following components to your `dialog` as children: + +- `header` (optional) +- `heading` (title, required) +- `divider` (optional) +- `content` (body, required) +- `button_group` (optional) +- `footer` (optional) + +### Examples + +A typical `dialog` with a title, contents, and action buttons can be created like so: + +```python +from deephaven import ui + + +@ui.component +def dialog_example1(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Publish", on_press=set_open.on), + ui.dialog( + ui.heading("Publish 3 pages"), + ui.content("Confirm publish?"), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button( + "Confirm", variant="accent", on_press=set_open.off, auto_focus=True + ), + ), + ), + is_open=is_open, + ) + + +my_dialog_example1 = dialog_example1() +``` + +A dismissable `dialog` forgoes its `button_group` in favor of rendering a close button at the top right of the `dialog`. + +```python +from deephaven import ui + +my_dialog_example2 = ui.dialog_trigger( + ui.action_button("Status"), + ui.dialog(ui.heading("Status"), ui.content("Printer Status: Connected")), + is_dismissable=True, +) +``` + +It is important to note that the `heading`, `header`, `content`, and `footer` content elements accept any renderable node, not just strings. This allows you to create `dialogs` for more complex workflows, such as including a form for the user to fill out or adding confirmation checkboxes. + +```python +from deephaven import ui + + +@ui.component +def dialog_example3(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Register", on_press=set_open.on), + ui.dialog( + ui.heading( + ui.flex( + ui.text("Register for newsletter"), + align_items="center", + gap="size-100", + ) + ), + ui.content( + ui.form( + ui.text_field(label="First Name", auto_focus=True), + ui.text_field(label="Last Name"), + ui.text_field(label="Street Address"), + ui.text_field(label="City"), + ui.checkbox( + "I want to receive updates for exclusive offers in my area." + ), + ) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Register", variant="accent", on_press=set_open.off), + ), + ), + is_open=is_open, + ) + + +my_dialog_example3 = dialog_example3() +``` + +## Events + +For dialogs, user defined callbacks should be chained with the close function in the `on_press` handler of the dialog's action buttons. The following example prints if the dialog's save or cancel button is clicked. + +```python +from deephaven import ui + + +@ui.component +def print_example(): + is_open, set_open = ui.use_boolean() + + def print_save(): + set_open.off() + print("Profile saved!") + + def print_cancel(): + set_open.off() + print("Provfile not saved!") + + return ui.dialog_trigger( + ui.action_button("Set Profile", on_press=set_open.on), + ui.dialog( + ui.heading("Profile"), + ui.content( + ui.form(ui.text_field(label="Name"), ui.checkbox("Make private")) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=print_cancel), + ui.button("Confirm", variant="accent", on_press=print_save), + ), + ), + is_open=is_open, + ) + + +my_print_example = print_example() +``` + +### Dismissable dialogs + +Dismissable dialogs support an optional `on_dismiss` prop that is triggered whenever the dialog's close button is clicked. Like non-dismissable dialogs, you must chain the close function with whatever callback you provide as `onDismiss`. If this event callback is not needed, the dismissable dialog will behave normally without passing this callback through. + +```python +from deephaven import ui + + +@ui.component +def dismissable_callback(): + is_open, set_open = ui.use_boolean() + + def print_dismiss(): + set_open.off() + print("Dialog dismissed.") + + return ui.dialog_trigger( + ui.action_button("Info", on_press=set_open.on), + ui.dialog( + ui.heading("Version Info"), + ui.content("Version 1.0.0, Copyright 2020"), + on_dismiss=print_dismiss, + ), + is_open=is_open, + is_dismissable=True, + ) + + +my_dismissable_callback = dismissable_callback() +``` + +## Visual options + +### Dialog types + +Dialogs can be rendered as modals, popovers, or trays. See the [`dialog_trigger`](dialog_trigger.md) docs for more information. + +```python +from deephaven import ui + + +my_modal = ui.dialog_trigger( + ui.action_button( + "Trigger Modal", + ), + ui.dialog( + ui.heading("Modal"), + ui.content("This is a modal."), + ), + is_dismissable=True, + type="modal", +) + +my_popover = ui.dialog_trigger( + ui.action_button( + "Trigger Popover", + ), + ui.dialog( + ui.heading("Popover"), + ui.content("This is a popover."), + ), + type="popover", +) + +my_tray = ui.dialog_trigger( + ui.action_button( + "Trigger Tray", + ), + ui.dialog( + ui.heading("Tray"), + ui.content("This is a tray."), + ), + type="tray", +) +``` + +### Size + +Only `modal` type dialogs support a user defined size prop. Note that the `fullscreen` and `fullscreenTakeover` sizes require the `dialog_trigger` `type` prop to be set to `fullscreen` or `fullscreenTakeover` respectively for container sizing considerations. + +```python +from deephaven import ui + + +@ui.component +def small_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Small", on_press=set_open.on), + ui.dialog( + ui.heading("Profile"), + ui.content( + ui.form(ui.text_field(label="Name"), ui.checkbox("Make private")) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Confirm", variant="accent", on_press=set_open.off), + ), + size="S", + ), + is_open=is_open, + ) + + +my_small_example = small_example() + + +@ui.component +def medium_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Medium", on_press=set_open.on), + ui.dialog( + ui.heading("Profile"), + ui.content( + ui.form(ui.text_field(label="Name"), ui.checkbox("Make private")) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Confirm", variant="accent", on_press=set_open.off), + ), + size="M", + ), + is_open=is_open, + ) + + +my_medium_example = medium_example() + + +@ui.component +def large_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Large", on_press=set_open.on), + ui.dialog( + ui.heading("Profile"), + ui.content( + ui.form(ui.text_field(label="Name"), ui.checkbox("Make private")) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Confirm", variant="accent", on_press=set_open.off), + ), + size="L", + ), + is_open=is_open, + ) + + +my_large_example = large_example() +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.dialog +``` diff --git a/plugins/ui/docs/components/dialog_trigger.md b/plugins/ui/docs/components/dialog_trigger.md new file mode 100644 index 000000000..afe44f869 --- /dev/null +++ b/plugins/ui/docs/components/dialog_trigger.md @@ -0,0 +1,366 @@ +# Dialog Trigger + +Dialog trigger serves as a wrapper around a dialog and its associated trigger, linking the dialog's open state with the trigger's press state. Additionally, it allows you to customize the type and positioning of the dialog. + +## Example + +```python +from deephaven import ui + + +my_dialog_trigger_example = ui.dialog_trigger( + ui.action_button( + "Disk Status", + ), + ui.dialog( + ui.heading("C://"), + ui.content("50% disk space remaining"), + ), + type="popover", +) +``` + +## Content + +The `dialog_trigger` accepts exactly two children: the element which triggers opening of the `dialog` and the `dialog` itself. The trigger must be the first child passed into the `dialog_trigger` and should be an element that supports press events. + +If your `dialog` has buttons within it that should close the dialog when pressed, you must use controlled mode to propagate the close function to the dialog's children. Dialogs that do not contain such interactive elements can provide the `dialog` component as is to the `dialog_trigger` as its second child. + +The example below demonstrates how to pass the close function to the dialog's buttons. + +```python +from deephaven import ui + + +@ui.component +def close_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Check connectivity", on_press=set_open.on), + ui.dialog( + ui.heading("Internet Speed Test"), + ui.content("Start speed test?"), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button("Confirm", variant="accent", on_press=set_open.off), + ), + ), + is_open=is_open, + ) + + +my_close_example = close_example() +``` + +## Dialog types + +By providing a `type` prop, you can specify the type of `dialog` that is rendered by your `dialog_trigger`. Note that pressing the `Esc` key will close the `dialog` regardless of its type. + +### Modal + +Modal dialogs create an underlay that blocks access to the underlying user interface until the dialog is closed. Sizing options can be found on the `dialog` page. Focus is trapped inside the modal as per the accessibility guidelines laid out by W3C. + +```python +from deephaven import ui + + +my_modal = ui.dialog_trigger( + ui.action_button( + "Trigger Modal", + ), + ui.dialog( + ui.heading("Modal"), + ui.content("This is a modal."), + ), + is_dismissable=True, + type="modal", +) +``` + +### Popover + +If a dialog without an underlay is needed, consider using a popover dialog. + +```python +from deephaven import ui + +my_popover = ui.dialog_trigger( + ui.action_button( + "Trigger Popover", + ), + ui.dialog( + ui.heading("Popover"), + ui.content("This is a popover."), + ), + type="popover", +) +``` + +### Tray + +Tray dialogs are typically used to portray information on mobile devices or smaller screens. + +```python +from deephaven import ui + +my_tray = ui.dialog_trigger( + ui.action_button( + "Trigger Tray", + ), + ui.dialog( + ui.heading("Tray"), + ui.content("This is a tray."), + ), + type="tray", +) +``` + +### Fullscreen + +Fullscreen dialogs are a fullscreen variant of the modal dialog, only revealing a small portion of the page behind the underlay. Use this variant for more complex workflows that do not fit in the available modal dialog sizes. This variant does not support `is_dismissible`. + +```python +from deephaven import ui + + +@ui.component +def fullscreen_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Trigger Fullscreen", on_press=set_open.on), + ui.dialog( + ui.heading("Fullscreen"), + ui.content( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sit\ + amet tristique risus. In sit amet suscipit lorem. Orci varius\ + natoque penatibus et magnis dis parturient montes, nascetur\ + ridiculus mus. In condimentum imperdiet metus non condimentum. Duis\ + eu velit et quam accumsan tempus at id velit. Duis elementum\ + elementum purus, id tempus mauris posuere a. Nunc vestibulum sapien\ + pellentesque lectus commodo ornare." + ), + ui.button_group( + ui.button("Close", variant="accent", on_press=set_open.off), + ), + ), + is_open=is_open, + type="fullscreen", + ) + + +my_fullscreen_example = fullscreen_example() +``` + +### Fullscreen takeover + +Fullscreen takeover dialogs are similar to the fullscreen variant except that the `dialog` covers the entire screen. + +```python +from deephaven import ui + + +@ui.component +def fullscreen_takeover_example(): + is_open, set_open = ui.use_boolean() + return ui.dialog_trigger( + ui.action_button("Trigger Fullscreen", on_press=set_open.on), + ui.dialog( + ui.heading("Fullscreen"), + ui.content( + ui.form( + ui.text_field(label="Name"), + ui.text_field(label="Email address"), + ui.checkbox("Make profile private"), + ) + ), + ui.button_group( + ui.button("Cancel", variant="secondary", on_press=set_open.off), + ui.button( + "Confirm", variant="accent", on_press=set_open.off, auto_focus=True + ), + ), + ), + is_open=is_open, + type="fullscreenTakeover", + ) + + +my_fullscreen_takeover_example = fullscreen_takeover_example() +``` + +### Dismissable + +If your modal dialog doesn't require the user to make a confirmation, you can set `is_dismissable` on the `dialog_trigger`. This adds a close button that the user can press to dismiss the `dialog`. + +```python +from deephaven import ui + +my_dialog_example2 = ui.dialog_trigger( + ui.action_button("Status"), + ui.dialog(ui.heading("Status"), ui.content("Printer Status: Connected")), + is_dismissable=True, +) +``` + +## Dialog placement + +Popover dialogs support a variety of placement options since they do not take over the user interface like modal or tray dialogs. + +### Placement + +The popover's placement can be adjusted using the `placement` prop. The full list of placements is: + +- `bottom` +- `bottom left` +- `bottom right` +- `bottom start` +- `bottom end` +- `top` +- `top left` +- `top right` +- `top start` +- `top end` +- `left` +- `left top` +- `left bottom` +- `start` +- `start top` +- `start bottom` +- `right` +- `right top` +- `right bottom` +- `end` +- `end top` +- `end bottom` + +```python +from deephaven import ui + +my_placement = ui.dialog_trigger( + ui.action_button("Trigger"), + ui.dialog( + ui.heading("The Heading"), + ui.content( + "This is a popover placed to the right of its\ + trigger and offset so the arrow is at the top of the dialog." + ), + ), + type="popover", + placement="right top", +) +``` + +### Offset and cross offset + +The popover dialog's offset can be adjusted using the `offset` and `cross_offset` props. The `offset` prop controls the spacing applied along the main axis between the element and its anchor element, whereas the `cross_offset` prop handles the spacing applied along the cross axis. + +Below is a popover offset by an additional 50px above the trigger. + +```python +from deephaven import ui + +my_offset = ui.dialog_trigger( + ui.action_button("Trigger"), + ui.dialog( + ui.heading("Offset"), + ui.content("Offset by an additional 50px."), + ), + type="popover", + offset=50, +) +``` + +Below is a popover cross offset by an additional 100px to the right of the trigger. + +```python +from deephaven import ui + +my_cross_offset = ui.dialog_trigger( + ui.action_button("Trigger"), + ui.dialog( + ui.heading("Cross offset"), + ui.content("Cross offset by an additional 100px."), + ), + type="popover", + cross_offset=100, +) +``` + +### Flipping + +By default, `dialog_trigger` attempts to flip popovers on the main axis in situations where the original placement would cause it to render out of view. This can be overridden by setting `should_flip` to `False`. + +```python +from deephaven import ui + +my_should_flip_example = ui.dialog_trigger( + ui.action_button("Trigger"), + ui.dialog( + ui.heading("The Heading"), + ui.content( + "This is a popover that won't flip if it can't fully render below the button." + ), + ), + type="popover", + placement="bottom", + should_flip=False, +) +``` + +### Container padding + +You can control the minimum padding required between the popover dialog and the surrounding container via the `container_padding` prop. This affects the positioning breakpoints that determine when the dialog will attempt to flip. + +The example below will flip the dialog from above the trigger button to below the trigger button if the dialog cannot render fully while maintaining 50px of padding between itself and the top of the browser. + +```python +from deephaven import ui + +my_should_flip_example = ui.dialog_trigger( + ui.action_button("Trigger"), + ui.dialog( + ui.heading("The Heading"), + ui.content("This is a popover."), + ), + type="popover", + placement="top", + container_padding=50, +) +``` + +## Events + +Dialog triggers accept an `on_open_change` handler that is triggered whenever the dialog is opened or closed. The example below updates a separate element with the dialog's current open state. + +```python +from deephaven import ui + + +@ui.component +def event_example(): + state, set_state = ui.use_state(False) + return ui.flex( + ui.dialog_trigger( + ui.action_button("Whispers"), + ui.dialog( + ui.heading("Whispers and DMs"), + ui.content("You have 0 new messages."), + ), + type="popover", + placement="top", + on_open_change=lambda is_open: set_state(is_open), + ), + ui.text(f"Current open state: {state}"), + align_items="center", + gap="size-100", + ) + + +my_event_example = event_example() +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.dialog +``` diff --git a/plugins/ui/docs/hooks/use_boolean.md b/plugins/ui/docs/hooks/use_boolean.md new file mode 100644 index 000000000..b1701832c --- /dev/null +++ b/plugins/ui/docs/hooks/use_boolean.md @@ -0,0 +1,61 @@ +# use_boolean + +`use_boolean` is a hook that adds a boolean variable to your component. Since it is a hook, it must be used at the top level of your component. It returns a tuple with two items: the current boolean value and a callable to set the variable. Updating state will cause the component to re-render (running the function again). This is a convenience hook for when you only need functions to set a boolean variable. For more complex state management, use `use_state`. + +## Boolean callable + +The boolean callable can be used to set the boolean value directly. It also includes the convenience methods `on` to set the variable to `True,` `off` to set the variable to `False,` and `toggle` to set the variable to the opposite of its current state. + +## Example + +```python +from deephaven import ui + + +@ui.component +def ui_boolean_example(): + value, set_value = ui.use_boolean() + + return [ + ui.text(f"{value}"), + ui.checkbox("My value", is_selected=value, on_change=set_value), + ui.switch("My value", is_selected=value, on_change=set_value), + ui.button("Set True", on_press=set_value.on), + ui.button("Set False", variant="negative", on_press=set_value.off), + ui.button("Toggle", variant="secondary", on_press=set_value.toggle), + ] + + +my_boolean_example = ui_boolean_example() +``` + +## Recommendations + +1. Convention is to use an unpacking assignment and name the state and setter function `value, set_value`. +2. When initializing the value with the result of a complex function, use an initializer function to avoid calling the complex function on every render. + +## Initializing the value + +`use_boolean` takes an optional parameter that intializes the value to an initial value: + +```python +value, set_value = ui.use_boolean(True) +``` + +If the parameter is omitted, the value will initalize to `False`: + +```python +value, set_value = ui.use_boolean() +``` + +If you pass a function into the initializer, it will be called on the first initialization. This is useful if you have an expensive computation to determine the value: + +```python +value, set_value = ui.use_boolean(lambda: complex_function()) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_boolean +``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 970429771..9e71407ed 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -53,6 +53,14 @@ "label": "date_range_picker", "path": "components/date_range_picker.md" }, + { + "label": "dialog", + "path": "components/dialog.md" + }, + { + "label": "dialog_trigger", + "path": "components/dialog_trigger.md" + }, { "label": "heading", "path": "components/heading.md" @@ -118,6 +126,10 @@ { "label": "Hooks", "items": [ + { + "label": "use_boolean", + "path": "hooks/use_boolean.md" + }, { "label": "use_effect", "path": "hooks/use_effect.md" diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 049beb11e..b67b0d73e 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -17,6 +17,8 @@ from .date_field import date_field from .date_picker import date_picker from .date_range_picker import date_range_picker +from .dialog import dialog +from .dialog_trigger import dialog_trigger from .flex import flex from .form import form from .fragment import fragment @@ -79,6 +81,8 @@ "date_field", "date_picker", "date_range_picker", + "dialog", + "dialog_trigger", "flex", "form", "fragment", diff --git a/plugins/ui/src/deephaven/ui/components/dialog.py b/plugins/ui/src/deephaven/ui/components/dialog.py new file mode 100644 index 000000000..d118c88c6 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/dialog.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from typing import Any, Callable + +from .types import ( + LayoutFlex, + DimensionValue, + AlignSelf, + JustifySelf, + Position, + AriaPressed, + CSSProperties, + DialogSize, +) + +from ..elements import Element +from .._internal.utils import create_props +from .basic import component_element + +DialogElement = Element + + +def dialog( + *children: Any, + size: DialogSize | None = None, + is_dismissable: bool | None = None, + on_dismiss: Callable[[], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | None = None, + grid_area: str | None = None, + grid_row: str | None = None, + grid_row_start: str | None = None, + grid_row_end: str | None = None, + grid_column: str | None = None, + grid_column_start: str | None = None, + grid_column_end: str | None = None, + margin: DimensionValue | None = None, + margin_top: DimensionValue | None = None, + margin_bottom: DimensionValue | None = None, + margin_start: DimensionValue | None = None, + margin_end: DimensionValue | None = None, + margin_x: DimensionValue | None = None, + margin_y: DimensionValue | None = None, + width: DimensionValue | None = None, + height: DimensionValue | None = None, + min_width: DimensionValue | None = None, + min_height: DimensionValue | None = None, + max_width: DimensionValue | None = None, + max_height: DimensionValue | None = None, + position: Position | None = None, + top: DimensionValue | None = None, + bottom: DimensionValue | None = None, + start: DimensionValue | None = None, + end: DimensionValue | None = None, + left: DimensionValue | None = None, + right: DimensionValue | None = None, + z_index: int | None = None, + is_hidden: bool | None = None, + id: str | None = None, + aria_label: str | None = None, + aria_labelledby: str | None = None, + aria_describedby: str | None = None, + aria_pressed: AriaPressed | None = None, + aria_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, + key: str | None = None, +) -> DialogElement: + """ + A dialog is a window containing contextual information, tasks, or workflows that appear over the user interface. + + Args: + *children: The contents of the Dialog. + size: The size of the Dialog. Only applies to "modal" type Dialogs. + is_dismissable: Whether the Dialog is dismissable. + on_dismiss: Handler that is called when the 'x' button of a dismissable Dialog is clicked. + flex: When used in a flex layout, specifies how the element will grow or shrink to fit the space available. + flex_grow: When used in a flex layout, specifies how much the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how much the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial size of the element. + align_self: Overrides the align_items property of a flex or grid container. + justify_self: Specifies how the element is justified inside a flex or grid container. + order: The layout order for the element within a flex or grid container. + grid_area: The name of the grid area to place the element in. + grid_row: The name of the grid row to place the element in. + grid_row_start: The name of the grid row to start the element in. + grid_row_end: The name of the grid row to end the element in. + grid_column: The name of the grid column to place the element in. + grid_column_start: The name of the grid column to start the element in. + grid_column_end: The name of the grid column to end the element in. + margin: The margin to apply around the element. + margin_top: The margin to apply above the element. + margin_bottom: The margin to apply below the element. + margin_start: The margin to apply before the element. + margin_end: The margin to apply after the element. + margin_x: The margin to apply to the left and right of the element. + margin_y: The margin to apply to the top and bottom of the element. + width: The width of the element. + height: The height of the element. + min_width: The minimum width of the element. + min_height: The minimum height of the element. + max_width: The maximum width of the element. + max_height: The maximum height of the element. + position: Specifies how the element is positioned. + top: The distance from the top of the containing element. + bottom: The distance from the bottom of the containing element. + start: The distance from the start of the containing element. + end: The distance from the end of the containing element. + left: The distance from the left of the containing element. + right: The distance from the right of the containing element. + z_index: The stack order of the element. + is_hidden: Whether the element is hidden. + id: A unique identifier for the element. + aria_label: The label for the element. + aria_labelledby: The id of the element that labels the element. + aria_describedby: The id of the element that describes the element. + aria_pressed: Whether the element is pressed. + aria_details: The details for the element. + UNSAFE_class_name: A CSS class to apply to the element. + UNSAFE_style: A CSS style to apply to the element. + key: A unique identifier used by React to render elements in a list. + + Returns: + The dialog element. + """ + children, props = create_props(locals()) + return component_element("Dialog", *children, **props) diff --git a/plugins/ui/src/deephaven/ui/components/dialog_trigger.py b/plugins/ui/src/deephaven/ui/components/dialog_trigger.py new file mode 100644 index 000000000..9544e22d8 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/dialog_trigger.py @@ -0,0 +1,53 @@ +from __future__ import annotations +from typing import Callable, Union +from .types import ( + DialogTriggerType, + DialogTriggerMobileType, + Placement, +) +from .basic import component_element +from ..elements import Element +from .._internal.utils import create_props + + +def dialog_trigger( + *children: Element, + type: DialogTriggerType | None = "modal", + mobile_type: DialogTriggerMobileType | None = None, + placement: Placement | None = "bottom", + is_open: bool | None = None, + default_open: bool | None = None, + container_padding: float | None = None, + offset: float | None = None, + cross_offset: float | None = None, + should_flip: bool | None = None, + hide_arrow: bool | None = None, + is_dismissable: bool | None = None, + is_keyboard_dismiss_disabled: bool | None = None, + on_open_change: Callable[[bool], None] | None = None, + key: str | None = None, +) -> Element: + """ + A dialog_trigger serves as a wrapper around a dialog and its associated trigger. + Args: + *children: The Dialog and its trigger element. + type: The type of Dialog that should be rendered. + mobile_type: The type of Dialog that should be rendered when on a mobile device. + placement: The placement of the popover relative to the action button. + is_open: Whether the popover is open by default (controlled). + default_open: Whether the popover is open by default (uncontrolled). + container_padding: The placement padding that should be applied between the element and its surrounding container. + offset: The additional offset applied along the main axis between the element and its anchor element. + cross_offset: The additional offset applied along the cross axis between the element and its anchor element. + should_flip: Whether the element should flip its orientation when there is insufficient room for it to render completely. + hide_arrow: Whether a popover type Dialog's arrow should be hidden. + is_dismissable: Whether a modal type Dialog should be dismissable. + is_keyboard_dismiss_disabled: Whether pressing the escape key to close the dialog should be disabled. + on_open_change: Handler that is called when the overlay's open state changes. + key: A unique identifier used by React to render elements in a list. + + Returns: + The dialog trigger element. + """ + children, props = create_props(locals()) + return component_element("DialogTrigger", *children, **props) diff --git a/plugins/ui/src/deephaven/ui/components/types/validate.py b/plugins/ui/src/deephaven/ui/components/types/validate.py index a95ff39d9..34fe0e62d 100644 --- a/plugins/ui/src/deephaven/ui/components/types/validate.py +++ b/plugins/ui/src/deephaven/ui/components/types/validate.py @@ -24,3 +24,10 @@ AutoCapitalizeModes = Literal["off", "none", "on", "sentences", "words", "characters"] DisabledBehavior = Literal["selection", "all"] + +DialogTriggerType = Literal[ + "modal", "popover", "tray", "fullscreen", "fullscreenTakeover" +] +DialogTriggerMobileType = Literal["modal", "fullscreen", "fullscreenTakeover"] + +DialogSize = Literal["S", "M", "L"] diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py index 3eff36bec..4401ed101 100644 --- a/plugins/ui/src/deephaven/ui/hooks/__init__.py +++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py @@ -12,6 +12,7 @@ from .use_cell_data import use_cell_data from .use_execution_context import use_execution_context from .use_liveness_scope import use_liveness_scope +from .use_boolean import use_boolean __all__ = [ @@ -29,4 +30,5 @@ "use_cell_data", "use_execution_context", "use_liveness_scope", + "use_boolean", ] diff --git a/plugins/ui/src/deephaven/ui/hooks/use_boolean.py b/plugins/ui/src/deephaven/ui/hooks/use_boolean.py new file mode 100644 index 000000000..59d484d9d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_boolean.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from .._internal import InitializerFunction, UpdaterFunction +from typing import Callable, cast, Protocol, Tuple, overload + +from .use_state import use_state +from .use_callback import use_callback +from .use_memo import use_memo + + +class BooleanCallable(Protocol): + on: Callable[[], None] + off: Callable[[], None] + toggle: Callable[[], None] + + def __call__(self, new_value: UpdaterFunction[bool]) -> None: + ... + + +@overload +def use_boolean() -> Tuple[bool, BooleanCallable]: + ... + + +@overload +def use_boolean( + initial_value: bool | InitializerFunction[bool], +) -> Tuple[bool, BooleanCallable]: + ... + + +def use_boolean( + initial_value: bool | InitializerFunction[bool] = False, +) -> Tuple[bool, BooleanCallable]: + """ + Hook to add a boolean variable to your component. The variable will persist across renders. + This is a convenience hook for when you only need functions to set update a boolean value. + For more complex state management, use use_state. + + Args: + initial_value: The initial value for the booelean. + It can be True or False, but passing a function will treat it as an initializer function. + An initializer function is called with no parameters once on the first render to get the initial value. + After the initial render the argument is ignored. + Default is False. + + Returns: + A tuple containing the current value of the boolean, and a callable to set the boolean. + """ + boolean, set = use_state(initial_value) + + on = use_callback(lambda: set(True), [set]) + off = use_callback(lambda: set(False), [set]) + toggle = use_callback(lambda: set(lambda old_value: not old_value), [set]) + + def init_callable(): + set.on = on + set.off = off + set.toggle = toggle + boolean_callable = cast(BooleanCallable, set) + return boolean_callable + + boolean_callable = use_memo(init_callable, [set, on, off, toggle]) + + return boolean, boolean_callable diff --git a/plugins/ui/src/js/src/elements/Dialog.tsx b/plugins/ui/src/js/src/elements/Dialog.tsx new file mode 100644 index 000000000..515facf1d --- /dev/null +++ b/plugins/ui/src/js/src/elements/Dialog.tsx @@ -0,0 +1,20 @@ +import React, { useCallback } from 'react'; +import { + Dialog as DHCDialog, + DialogProps as DHCDialogProps, +} from '@deephaven/components'; + +export function Dialog(props: DHCDialogProps): JSX.Element { + const { onDismiss: onDismissProp, ...otherProps } = props; + const onDismissCallback = useCallback( + () => onDismissProp?.(), + [onDismissProp] + ); + const onDismiss = onDismissProp != null ? onDismissCallback : undefined; + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +Dialog.displayName = 'Dialog'; + +export default Dialog; diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index e2a3fa596..bc6712560 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -7,6 +7,7 @@ export * from './ContextualHelp'; export * from './DateField'; export * from './DatePicker'; export * from './DateRangePicker'; +export * from './Dialog'; export * from './Flex'; export * from './Form'; export * from './Grid'; diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index 0b6f246f0..3823ab7cb 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -37,6 +37,8 @@ export const ELEMENT_NAME = { dateField: uiComponentName('DateField'), datePicker: uiComponentName('DatePicker'), dateRangePicker: uiComponentName('DateRangePicker'), + dialog: uiComponentName('Dialog'), + dialogTrigger: uiComponentName('DialogTrigger'), flex: uiComponentName('Flex'), form: uiComponentName('Form'), fragment: uiComponentName('Fragment'), diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index a6c394a50..4154880a0 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -10,6 +10,7 @@ import { SpectrumCheckbox as Checkbox, CheckboxGroup, Content, + DialogTrigger, Heading, Item, ListActionGroup, @@ -54,6 +55,7 @@ import { DateField, DatePicker, DateRangePicker, + Dialog, Flex, Form, Grid, @@ -118,6 +120,8 @@ export const elementComponentMap = { [ELEMENT_NAME.dateField]: DateField, [ELEMENT_NAME.datePicker]: DatePicker, [ELEMENT_NAME.dateRangePicker]: DateRangePicker, + [ELEMENT_NAME.dialog]: Dialog, + [ELEMENT_NAME.dialogTrigger]: DialogTrigger, [ELEMENT_NAME.flex]: Flex, [ELEMENT_NAME.form]: Form, [ELEMENT_NAME.fragment]: React.Fragment, diff --git a/plugins/ui/test/deephaven/ui/hooks/test_boolean.py b/plugins/ui/test/deephaven/ui/hooks/test_boolean.py new file mode 100644 index 000000000..aa097e3a6 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/hooks/test_boolean.py @@ -0,0 +1,68 @@ +from operator import itemgetter +from ..BaseTest import BaseTestCase +from .render_utils import render_hook + + +class UseBooleanTestCase(BaseTestCase): + def test_boolean(self): + from deephaven.ui.hooks import use_boolean + + def _test_boolean(initial_value: bool = False): + value, set_value = use_boolean(initial_value) + return value, set_value + + # Initial render + render_result = render_hook(_test_boolean) + + result, rerender = itemgetter("result", "rerender")(render_result) + value, set_value = result + + self.assertEqual(value, False) + + # Rerender with new value, but should retain existing state + rerender(initial_value=True) + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, False) + + # Set to a True + set_value(True) + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, True) + + # Set to False + set_value(False) + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, False) + + # Set to a True with on + set_value.on() + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, True) + + # Set to a False with off + set_value.off() + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, False) + + # Set to a True with toogle + set_value.toggle() + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, True) + + # Set to a False with toogle + set_value.toggle() + rerender() + result = itemgetter("result")(render_result) + value, set_value = result + self.assertEqual(value, False)