From 38b202fbab8de63412fe40cbaabe86556ef3b5e1 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Thu, 1 Feb 2024 13:23:52 -0500 Subject: [PATCH] feat: Serialize press events for press event callbacks (#236) - Serialize press events so they get passed back to the server (Fixes #76) - Add pydocs for `action_button` (Fixes #235) BREAKING CHANGE: All button `on_press` handlers now must take a positional argument for the event. For example, when using an action button: ```python import deephaven.ui as ui from deephaven.ui import use_state @ui.component def counter(): count, set_count = use_state(0) return ui.action_button( # Any lambda passed into the `on_press` method now needs to accept the event argument f"You pressed me {count} times", on_press=lambda e: set_count(count + 1) ) c = counter() ``` --- docker/data/storage/notebooks/DEMO.md | 8 +- package-lock.json | 2 + plugins/ui/examples/README.md | 2 +- .../ui/src/deephaven/ui/_internal/utils.py | 34 ++- .../ui/components/spectrum/__init__.py | 1 + .../ui/components/spectrum/action_button.py | 195 ++++++++++++++++++ .../deephaven/ui/components/spectrum/basic.py | 12 +- .../ui/components/spectrum/events.py | 104 ++++++++++ .../deephaven/ui/components/spectrum/flex.py | 72 ++----- .../ui/components/spectrum/layout.py | 103 +++++++++ plugins/ui/src/js/package.json | 1 + .../ui/src/js/src/spectrum/ActionButton.tsx | 23 +-- plugins/ui/src/js/src/spectrum/Button.tsx | 22 +- .../ui/src/js/src/spectrum/useButtonProps.ts | 79 +++++++ .../js/src/spectrum/useFocusEventCallback.ts | 44 ++++ .../src/spectrum/useKeyboardEventCallback.ts | 36 ++++ .../js/src/spectrum/usePressEventCallback.ts | 43 ++++ plugins/ui/test/deephaven/ui/test_utils.py | 44 ++++ 18 files changed, 720 insertions(+), 105 deletions(-) create mode 100644 plugins/ui/src/deephaven/ui/components/spectrum/action_button.py create mode 100644 plugins/ui/src/deephaven/ui/components/spectrum/events.py create mode 100644 plugins/ui/src/deephaven/ui/components/spectrum/layout.py create mode 100644 plugins/ui/src/js/src/spectrum/useButtonProps.ts create mode 100644 plugins/ui/src/js/src/spectrum/useFocusEventCallback.ts create mode 100644 plugins/ui/src/js/src/spectrum/useKeyboardEventCallback.ts create mode 100644 plugins/ui/src/js/src/spectrum/usePressEventCallback.ts diff --git a/docker/data/storage/notebooks/DEMO.md b/docker/data/storage/notebooks/DEMO.md index 414fad946..7d7749234 100644 --- a/docker/data/storage/notebooks/DEMO.md +++ b/docker/data/storage/notebooks/DEMO.md @@ -29,7 +29,7 @@ We define our `counter` component as a function using the `@ui.component` decora def counter(): count, set_count = ui.use_state(0) return ui.action_button( - f"You pressed me {count} times", on_press=lambda: set_count(count + 1) + f"You pressed me {count} times", on_press=lambda _: set_count(count + 1) ) @@ -48,7 +48,7 @@ def my_input(): return ui.flex( ui.action_button( - f"You pressed me {count} times", on_press=lambda: set_count(count + 1) + f"You pressed me {count} times", on_press=lambda _: set_count(count + 1) ), ui.text_field(value=text, on_change=set_text), ui.text(f"You typed {text}"), @@ -241,10 +241,10 @@ def order_table(): ) ) - def handle_buy(): + def handle_buy(_): submit_order(sym, size, "buy") - def handle_sell(): + def handle_sell(_): submit_order(sym, size, "sell") return [ diff --git a/package-lock.json b/package-lock.json index 905d37529..cdda54bd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31244,6 +31244,7 @@ "@deephaven/react-hooks": "^0.60.0", "@deephaven/utils": "^0.60.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@react-types/shared": "^3.22.0", "json-rpc-2.0": "^1.6.0", "shortid": "^2.2.16" }, @@ -34542,6 +34543,7 @@ "@deephaven/react-hooks": "^0.60.0", "@deephaven/utils": "^0.60.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@react-types/shared": "^3.22.0", "@types/react": "^17.0.2", "@vitejs/plugin-react-swc": "^3.0.0", "json-rpc-2.0": "^1.6.0", diff --git a/plugins/ui/examples/README.md b/plugins/ui/examples/README.md index 7579429a7..c5d60dee2 100644 --- a/plugins/ui/examples/README.md +++ b/plugins/ui/examples/README.md @@ -36,7 +36,7 @@ from deephaven.ui import use_state def counter(): count, set_count = use_state(0) return ui.action_button( - f"You pressed me {count} times", on_press=lambda: set_count(count + 1) + f"You pressed me {count} times", on_press=lambda _: set_count(count + 1) ) diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 492d5c427..9ea4d34fd 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Any +from typing import Any, Callable + +_UNSAFE_PREFIX = "UNSAFE_" def get_component_name(component: Any) -> str: @@ -46,23 +48,45 @@ def to_camel_case(snake_case_text: str) -> str: The camelCase string. """ components = snake_case_text.split("_") - return components[0] + "".join(x.title() for x in components[1:]) + return components[0] + "".join((x[0].upper() + x[1:]) for x in components[1:]) + + +def to_camel_case_skip_unsafe(snake_case_text: str) -> str: + """ + Convert a snake_case string to camelCase. Leaves the `UNSAFE_` prefix intact if present. + + Args: + snake_case_text: The snake_case string to convert. + + Returns: + The camelCase string with the `UNSAFE_` prefix intact if present. + """ + if snake_case_text.startswith(_UNSAFE_PREFIX): + return _UNSAFE_PREFIX + to_camel_case(snake_case_text[len(_UNSAFE_PREFIX) :]) + return to_camel_case(snake_case_text) -# TODO: Take an exclusion regex? function? for keys we do not want to convert -def dict_to_camel_case(snake_case_dict: dict[str, Any]) -> dict[str, Any]: +def dict_to_camel_case( + snake_case_dict: dict[str, Any], + omit_none: bool = True, + convert_key: Callable[[str], str] = to_camel_case_skip_unsafe, +) -> dict[str, Any]: """ Convert a dict with snake_case keys to a dict with camelCase keys. Args: snake_case_dict: The snake_case dict to convert. + omit_none: Whether to omit keys with a value of None. + convert_key: The function to convert the keys. Can be used to customize the conversion behaviour Returns: The camelCase dict. """ camel_case_dict: dict[str, Any] = {} for key, value in snake_case_dict.items(): - camel_case_dict[to_camel_case(key)] = value + if omit_none and value is None: + continue + camel_case_dict[convert_key(key)] = value return camel_case_dict diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py index 6b22be14b..48a10eb73 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py @@ -1,2 +1,3 @@ +from .action_button import * from .basic import * from .flex import * diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/action_button.py b/plugins/ui/src/deephaven/ui/components/spectrum/action_button.py new file mode 100644 index 000000000..d248da28e --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/spectrum/action_button.py @@ -0,0 +1,195 @@ +from __future__ import annotations +from typing import Any, Callable +from .events import ( + ButtonType, + FocusEventCallable, + KeyboardEventCallable, + PressEventCallable, + StaticColor, +) +from .layout import ( + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Number, + Position, +) +from .basic import spectrum_element +from ...elements import Element + + +def action_button( + *children: Any, + type: ButtonType = "button", + on_press: PressEventCallable | None = None, + on_press_start: PressEventCallable | None = None, + on_press_end: PressEventCallable | None = None, + on_press_up: PressEventCallable | None = None, + on_press_change: Callable[[bool], None] | None = None, + on_focus: FocusEventCallable | None = None, + on_blur: FocusEventCallable | None = None, + on_focus_change: Callable[[bool], None] | None = None, + on_key_down: KeyboardEventCallable | None = None, + on_key_up: KeyboardEventCallable | None = None, + auto_focus: bool | None = None, + is_disabled: bool | None = None, + is_quiet: bool | None = None, + static_color: StaticColor | None = None, + flex: LayoutFlex | None = None, + flex_grow: Number | None = None, + flex_shrink: Number | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: Number | 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: Number | None = None, + is_hidden: bool | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> Element: + """ + ActionButtons allow users to perform an action. They're used for similar, task-based options within a workflow, and are ideal for interfaces where buttons aren't meant to draw a lot of attention. + Python implementation for the Adobe React Spectrum ActionButton component: https://react-spectrum.adobe.com/react-spectrum/ActionButton.html + + Args: + *children: The content to display inside the button. + type: The type of button to render. (default: "button") + on_press: Function called when the button is pressed. + on_press_start: Function called when the button is pressed. + on_press_end: Function called when a press interaction ends, either over the target or when the pointer leaves the target. + on_press_up: Function called when the button is released. + on_press_change: Function called when the press state changes. + on_focus: Function called when the button receives focus. + on_blur: Function called when the button loses focus. + on_focus_change: Function called when the focus state changes. + on_key_down: Function called when a key is pressed. + on_key_up: Function called when a key is released. + auto_focus: Whether the button should automatically get focus when the page loads. + is_disabled: Whether the button is disabled. + is_quiet: Whether the button should be quiet. + static_color: The static color style to apply. Useful when the button appears over a color background. + 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. + UNSAFE_class_name: A CSS class to apply to the element. + UNSAFE_style: A CSS style to apply to the element. + """ + return spectrum_element( + "ActionButton", + *children, + type=type, + on_press=on_press, + on_press_start=on_press_start, + on_press_end=on_press_end, + on_press_up=on_press_up, + on_press_change=on_press_change, + on_focus=on_focus, + on_blur=on_blur, + on_focus_change=on_focus_change, + on_key_down=on_key_down, + on_key_up=on_key_up, + auto_focus=auto_focus, + is_disabled=is_disabled, + is_quiet=is_quiet, + static_color=static_color, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_row=grid_row, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + grid_column=grid_column, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + max_width=max_width, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + start=start, + end=end, + left=left, + right=right, + z_index=z_index, + is_hidden=is_hidden, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + ) diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/basic.py b/plugins/ui/src/deephaven/ui/components/spectrum/basic.py index 2a7d4a20d..cf1d6751b 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/basic.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/basic.py @@ -1,7 +1,9 @@ +from __future__ import annotations +from typing import Any from ...elements import BaseElement -def spectrum_element(name: str, /, *children, **props): +def spectrum_element(name: str, /, *children: Any, **props: Any) -> BaseElement: """ Base class for UI elements that are part of the Spectrum design system. All names are automatically prefixed with "deephaven.ui.spectrum.", and all props are automatically camelCased. @@ -9,14 +11,6 @@ def spectrum_element(name: str, /, *children, **props): return BaseElement(f"deephaven.ui.spectrum.{name}", *children, **props) -def action_button(*children, **props): - """ - Python implementation for the Adobe React Spectrum ActionButton component. - https://react-spectrum.adobe.com/react-spectrum/ActionButton.html - """ - return spectrum_element("ActionButton", *children, **props) - - def button(*children, **props): """ Python implementation for the Adobe React Spectrum Button component. diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/events.py b/plugins/ui/src/deephaven/ui/components/spectrum/events.py new file mode 100644 index 000000000..009fa1f75 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/spectrum/events.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from typing import Callable, Literal, TypedDict + + +class FocusEvent(TypedDict): + type: Literal["focus", "blur"] + """ + The type of focus event being fired. + """ + + target: str | None + """ + The name of the target element of the focus event. + """ + + relatedTarget: str | None + """ + The name of the related target element of the focus event. + """ + + +class KeyboardEvent(TypedDict): + code: str + """ + Returns a string with the code value of the key represented by the event. + """ + + key: str + """ + Returns a string with the key value of the key represented by the event. + """ + + shiftKey: bool + """ + Whether the shift keyboard modifier was held during the press event. + """ + + ctrlKey: bool + """ + Whether the ctrl keyboard modifier was held during the press event. + """ + + metaKey: bool + """ + Whether the meta keyboard modifier was held during the press event. + """ + + altKey: bool + """ + Whether the alt keyboard modifier was held during the press event. + """ + + repeat: bool + """ + Returns a boolean value that is `True` if the key is being held down such that it is automatically repeating. + """ + + +class PressEvent(TypedDict): + type: PressEventType + """ + The type of press event being fired. + """ + + pointerType: PointerType + """ + The pointer type that triggered the press event. + """ + + target: str | None + """ + The name of the target element of the press event. + """ + + shiftKey: bool + """ + Whether the shift keyboard modifier was held during the press event. + """ + + ctrlKey: bool + """ + Whether the ctrl keyboard modifier was held during the press event. + """ + + metaKey: bool + """ + Whether the meta keyboard modifier was held during the press event. + """ + + altKey: bool + """ + Whether the alt keyboard modifier was held during the press event. + """ + + +PointerType = Literal["mouse", "touch", "pen", "keyboard", "virtual"] +PressEventType = Literal["pressstart", "pressend", "pressup", "press"] + +StaticColor = Literal["white", "black"] +ButtonType = Literal["button", "submit", "reset"] + +FocusEventCallable = Callable[[FocusEvent], None] +KeyboardEventCallable = Callable[[KeyboardEvent], None] +PressEventCallable = Callable[[PressEvent], None] diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/flex.py b/plugins/ui/src/deephaven/ui/components/spectrum/flex.py index ca17a7b23..7634ebab0 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/flex.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/flex.py @@ -1,61 +1,27 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import Any from .basic import spectrum_element +from .layout import ( + Direction, + Wrap, + JustifyContent, + AlignContent, + AlignItems, + DimensionValue, +) def flex( - *children, - direction: Literal["row", "column", "row-reverse", "column-reverse"] = None, - wrap: Literal["wrap", "nowrap", "wrap-reverse"] = None, - justify_content: Literal[ - "start", - "end", - "center", - "left", - "right", - "space-between", - "space-around", - "space-evenly", - "stretch", - "baseline", - "first baseline", - "last baseline", - "safe center", - "unsafe center", - ] = None, - align_content: Literal[ - "start", - "end", - "center", - "space-between", - "space-around", - "space-evenly", - "stretch", - "baseline", - "first baseline", - "last baseline", - "safe center", - "unsafe center", - ] = None, - align_items: Optional[ - Literal[ - "start", - "end", - "center", - "stretch", - "self-start", - "self-end", - "baseline", - "first baseline", - "last baseline", - "safe center", - "unsafe center", - ] - ] = None, - gap: Optional[str | int | float] = None, - column_gap: Optional[str | int | float] = None, - row_gap: Optional[str | int | float] = None, - **props, + *children: Any, + direction: Direction | None = None, + wrap: Wrap | None = None, + justify_content: JustifyContent | None = None, + align_content: AlignContent | None = None, + align_items: AlignItems | None = None, + gap: DimensionValue | None = None, + column_gap: DimensionValue | None = None, + row_gap: DimensionValue | None = None, + **props: Any, ): """ Python implementation for the Adobe React Spectrum Flex component. diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/layout.py b/plugins/ui/src/deephaven/ui/components/spectrum/layout.py new file mode 100644 index 000000000..9d203262a --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/spectrum/layout.py @@ -0,0 +1,103 @@ +from __future__ import annotations +from typing import Any, Dict, Literal, Union + +# Layout typings + +Direction = Literal["row", "column", "row-reverse", "column-reverse"] +Wrap = Literal["wrap", "nowrap", "wrap-reverse"] + +AlignContent = Literal[ + "start", + "end", + "center", + "space-between", + "space-around", + "space-evenly", + "stretch", + "baseline", + "first baseline", + "last baseline", + "safe center", + "unsafe center", +] + +AlignItems = Literal[ + "start", + "end", + "center", + "stretch", + "self-start", + "self-end", + "baseline", + "first baseline", + "last baseline", + "safe center", + "unsafe center", +] + +AlignSelf = Literal[ + "auto", + "normal", + "start", + "end", + "center", + "flex-start", + "flex-end", + "self-start", + "self-end", + "stretch", +] + +JustifyContent = Literal[ + "start", + "end", + "center", + "left", + "right", + "space-between", + "space-around", + "space-evenly", + "stretch", + "baseline", + "first baseline", + "last baseline", + "safe center", + "unsafe center", +] + +JustifySelf = Literal[ + "auto", + "normal", + "start", + "end", + "flex-start", + "flex-end", + "self-start", + "self-end", + "center", + "left", + "right", + "stretch", +] + +Number = Union[int, float] + +LayoutFlex = Union[str, Number, bool] +""" +The flex CSS shorthand property sets how a flex item will grow or shrink to fit the space available in its flex container. +""" + +DimensionValue = Union[str, Number] +""" +A dimension value can be a string providing a unit, such as "10px", or a number, which is assumed to be in pixels. +""" + +Position = Literal["static", "relative", "absolute", "fixed", "sticky"] +""" +The position CSS property sets how an element is positioned in a document. The top, right, bottom, and left properties determine the final location of positioned elements. +""" + +CSSProperties = Dict[str, Any] +""" +A dictionary of CSS properties. +""" diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json index 763999d86..cc40aae61 100644 --- a/plugins/ui/src/js/package.json +++ b/plugins/ui/src/js/package.json @@ -55,6 +55,7 @@ "@deephaven/react-hooks": "^0.60.0", "@deephaven/utils": "^0.60.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@react-types/shared": "^3.22.0", "json-rpc-2.0": "^1.6.0", "shortid": "^2.2.16" }, diff --git a/plugins/ui/src/js/src/spectrum/ActionButton.tsx b/plugins/ui/src/js/src/spectrum/ActionButton.tsx index 702a721f9..f04f800dd 100644 --- a/plugins/ui/src/js/src/spectrum/ActionButton.tsx +++ b/plugins/ui/src/js/src/spectrum/ActionButton.tsx @@ -1,28 +1,17 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { ActionButton as SpectrumActionButton, SpectrumActionButtonProps, } from '@adobe/react-spectrum'; -import { mapSpectrumProps } from './mapSpectrumProps'; +import { SerializedButtonEventProps, useButtonProps } from './useButtonProps'; function ActionButton( - props: SpectrumActionButtonProps & { onPress?: () => void } + props: SpectrumActionButtonProps & SerializedButtonEventProps ) { - const { onPress: propOnPress, ...otherProps } = props; + const buttonProps = useButtonProps(props); - const onPress = useCallback( - e => { - // The PressEvent from React Spectrum is not serializable (contains circular references). We're just dropping the event here but we should probably convert it. - // TODO(#76): Need to serialize PressEvent and send with the callback instead of just dropping it. - propOnPress?.(); - }, - [propOnPress] - ); - - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; } export default ActionButton; diff --git a/plugins/ui/src/js/src/spectrum/Button.tsx b/plugins/ui/src/js/src/spectrum/Button.tsx index cbfb6ba21..c15ee7939 100644 --- a/plugins/ui/src/js/src/spectrum/Button.tsx +++ b/plugins/ui/src/js/src/spectrum/Button.tsx @@ -1,25 +1,15 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { Button as SpectrumButton, SpectrumButtonProps, } from '@adobe/react-spectrum'; +import { SerializedButtonEventProps, useButtonProps } from './useButtonProps'; -function Button(props: SpectrumButtonProps & { onPress?: () => void }) { - const { onPress: propOnPress, ...otherProps } = props; +function Button(props: SpectrumButtonProps & SerializedButtonEventProps) { + const buttonProps = useButtonProps(props); - const onPress = useCallback( - e => { - // The PressEvent from React Spectrum is not serializable (contains circular references). We're just dropping the event here but we should probably convert it. - // TODO(#76): Need to serialize PressEvent and send with the callback instead of just dropping it. - propOnPress?.(); - }, - [propOnPress] - ); - - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; } Button.displayName = 'Button'; diff --git a/plugins/ui/src/js/src/spectrum/useButtonProps.ts b/plugins/ui/src/js/src/spectrum/useButtonProps.ts new file mode 100644 index 000000000..4ecf16829 --- /dev/null +++ b/plugins/ui/src/js/src/spectrum/useButtonProps.ts @@ -0,0 +1,79 @@ +import { + SerializedPressEventCallback, + usePressEventCallback, +} from './usePressEventCallback'; +import { + SerializedFocusEventCallback, + useFocusEventCallback, +} from './useFocusEventCallback'; +import { + SerializedKeyboardEventCallback, + useKeyboardEventCallback, +} from './useKeyboardEventCallback'; +import { mapSpectrumProps } from './mapSpectrumProps'; + +export type SerializedButtonEventProps = { + /** Handler that is called when the press is released over the target. */ + onPress?: SerializedPressEventCallback; + + /** Handler that is called when a press interaction starts. */ + onPressStart?: SerializedPressEventCallback; + /** + * Handler that is called when a press interaction ends, either + * over the target or when the pointer leaves the target. + */ + onPressEnd?: SerializedPressEventCallback; + + /** + * Handler that is called when a press is released over the target, regardless of + * whether it started on the target or not. + */ + onPressUp?: SerializedPressEventCallback; + + /** Handler that is called when the element receives focus. */ + onFocus?: SerializedFocusEventCallback; + + /** Handler that is called when the element loses focus. */ + onBlur?: SerializedFocusEventCallback; + + /** Handler that is called when a key is pressed */ + onKeyDown?: SerializedKeyboardEventCallback; + + /** Handler that is called when a key is released */ + onKeyUp?: SerializedKeyboardEventCallback; +}; + +export function useButtonProps(props: SerializedButtonEventProps & T) { + const { + onPress: propOnPress, + onPressStart: propsOnPressStart, + onPressEnd: propsOnPressEnd, + onPressUp: propsOnPressUp, + onFocus: propOnFocus, + onBlur: propOnBlur, + onKeyDown: propOnKeyDown, + onKeyUp: propOnKeyUp, + ...otherProps + } = props; + + const onPress = usePressEventCallback(propOnPress); + const onPressStart = usePressEventCallback(propsOnPressStart); + const onPressEnd = usePressEventCallback(propsOnPressEnd); + const onPressUp = usePressEventCallback(propsOnPressUp); + const onFocus = useFocusEventCallback(propOnFocus); + const onBlur = useFocusEventCallback(propOnBlur); + const onKeyDown = useKeyboardEventCallback(propOnKeyDown); + const onKeyUp = useKeyboardEventCallback(propOnKeyUp); + + return { + onPress, + onPressStart, + onPressEnd, + onPressUp, + onFocus, + onBlur, + onKeyDown, + onKeyUp, + ...mapSpectrumProps(otherProps), + }; +} diff --git a/plugins/ui/src/js/src/spectrum/useFocusEventCallback.ts b/plugins/ui/src/js/src/spectrum/useFocusEventCallback.ts new file mode 100644 index 000000000..5ac85d186 --- /dev/null +++ b/plugins/ui/src/js/src/spectrum/useFocusEventCallback.ts @@ -0,0 +1,44 @@ +import { FocusEvent, useCallback } from 'react'; + +export function serializeFocusEvent(event: FocusEvent): SerializedFocusEvent { + const { relatedTarget, target, type } = event; + const targetName = + target instanceof Element ? target.getAttribute('name') : undefined; + const relatedTargetName = + relatedTarget instanceof Element + ? relatedTarget.getAttribute('name') + : undefined; + return { + type, + target: targetName ?? undefined, + relatedTarget: relatedTargetName ?? undefined, + }; +} + +/** + * FocusEvent serialized so it can be sent to the server. + * Replaces the target and relatedTarget with the `name` of the elements (if available) + */ +export type SerializedFocusEvent = { + target?: string; + relatedTarget?: string; + type: string; +}; + +export type SerializedFocusEventCallback = ( + event: SerializedFocusEvent +) => void; + +/** + * Get a callback function to be passed into spectrum components + * @param callback FocusEvent callback to be called with the serialized event + * @returns A callback to be passed into the Spectrum component that transforms the event and calls the provided callback + */ +export function useFocusEventCallback(callback?: SerializedFocusEventCallback) { + return useCallback( + (e: FocusEvent) => { + callback?.(serializeFocusEvent(e)); + }, + [callback] + ); +} diff --git a/plugins/ui/src/js/src/spectrum/useKeyboardEventCallback.ts b/plugins/ui/src/js/src/spectrum/useKeyboardEventCallback.ts new file mode 100644 index 000000000..c0cba674b --- /dev/null +++ b/plugins/ui/src/js/src/spectrum/useKeyboardEventCallback.ts @@ -0,0 +1,36 @@ +import { KeyboardEvent, useCallback } from 'react'; + +export function serializeKeyboardEvent( + event: KeyboardEvent +): SerializedKeyboardEvent { + const { code, key, shiftKey, ctrlKey, metaKey, altKey, repeat } = event; + return { code, key, shiftKey, ctrlKey, metaKey, altKey, repeat }; +} + +/** + * KeyboardEvent serialized so it can be sent to the server. + */ +export type SerializedKeyboardEvent = Pick< + KeyboardEvent, + 'code' | 'key' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'altKey' | 'repeat' +>; + +export type SerializedKeyboardEventCallback = ( + event: SerializedKeyboardEvent +) => void; + +/** + * Get a callback function to be passed into spectrum components + * @param callback KeyboardEvent callback to be called with the serialized event + * @returns A callback to be passed into the Spectrum component that transforms the event and calls the provided callback + */ +export function useKeyboardEventCallback( + callback?: SerializedKeyboardEventCallback +) { + return useCallback( + (e: KeyboardEvent) => { + callback?.(serializeKeyboardEvent(e)); + }, + [callback] + ); +} diff --git a/plugins/ui/src/js/src/spectrum/usePressEventCallback.ts b/plugins/ui/src/js/src/spectrum/usePressEventCallback.ts new file mode 100644 index 000000000..a2f176bfe --- /dev/null +++ b/plugins/ui/src/js/src/spectrum/usePressEventCallback.ts @@ -0,0 +1,43 @@ +import { PressEvent } from '@react-types/shared'; +import { useCallback } from 'react'; + +export function serializePressEvent(event: PressEvent): SerializedPressEvent { + const { target, type, pointerType, shiftKey, ctrlKey, metaKey, altKey } = + event; + return { + target: target?.getAttribute('name') ?? undefined, + type, + pointerType, + shiftKey, + ctrlKey, + metaKey, + altKey, + }; +} + +/** + * PressEvent serialized so it can be sent to the server. + * Replaces the target with the `name` of the target element (if available) + */ +export type SerializedPressEvent = Pick< + PressEvent, + 'type' | 'pointerType' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'altKey' +> & { target?: string }; + +export type SerializedPressEventCallback = ( + event: SerializedPressEvent +) => void; + +/** + * Get a callback function to be passed into spectrum components + * @param callback PressEvent callback to be called with the serialized event + * @returns A callback to be passed into the Spectrum component that transforms the event and calls the provided callback + */ +export function usePressEventCallback(callback?: SerializedPressEventCallback) { + return useCallback( + (e: PressEvent) => { + callback?.(serializePressEvent(e)); + }, + [callback] + ); +} diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py index 1a301d8c1..57b9a5783 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -25,6 +25,31 @@ def test_to_camel_case(self): self.assertEqual(to_camel_case("first_word"), "firstWord") self.assertEqual(to_camel_case("alreadyCamelCase"), "alreadyCamelCase") self.assertEqual(to_camel_case(""), "") + self.assertEqual(to_camel_case("UNSAFE_style"), "UNSAFEStyle") + self.assertEqual(to_camel_case("UNSAFE_class_name"), "UNSAFEClassName") + self.assertEqual(to_camel_case("UNSAFE_className"), "UNSAFEClassName") + self.assertEqual(to_camel_case("unsafe_style"), "unsafeStyle") + + def test_to_camel_case_skip_unsafe(self): + from deephaven.ui._internal.utils import to_camel_case_skip_unsafe + + self.assertEqual(to_camel_case_skip_unsafe("test_string"), "testString") + self.assertEqual(to_camel_case_skip_unsafe("test_string_2"), "testString2") + self.assertEqual(to_camel_case_skip_unsafe("align_items"), "alignItems") + self.assertEqual(to_camel_case_skip_unsafe("First_Word"), "FirstWord") + self.assertEqual(to_camel_case_skip_unsafe("first_word"), "firstWord") + self.assertEqual( + to_camel_case_skip_unsafe("alreadyCamelCase"), "alreadyCamelCase" + ) + self.assertEqual(to_camel_case_skip_unsafe(""), "") + self.assertEqual(to_camel_case_skip_unsafe("UNSAFE_style"), "UNSAFE_style") + self.assertEqual( + to_camel_case_skip_unsafe("UNSAFE_class_name"), "UNSAFE_className" + ) + self.assertEqual( + to_camel_case_skip_unsafe("UNSAFE_className"), "UNSAFE_className" + ) + self.assertEqual(to_camel_case_skip_unsafe("unsafe_style"), "unsafeStyle") def test_dict_to_camel_case(self): from deephaven.ui._internal.utils import dict_to_camel_case @@ -37,6 +62,25 @@ def test_dict_to_camel_case(self): dict_to_camel_case({"alreadyCamelCase": "foo", "align_items": "bar"}), {"alreadyCamelCase": "foo", "alignItems": "bar"}, ) + self.assertDictEqual( + dict_to_camel_case({"foo": None, "bar": "biz"}), + {"bar": "biz"}, + ) + self.assertDictEqual( + dict_to_camel_case({"foo": None, "bar": "biz"}, omit_none=False), + {"foo": None, "bar": "biz"}, + ) + self.assertDictEqual( + dict_to_camel_case({"bar": "biz", "UNSAFE_class_name": "harry"}), + {"bar": "biz", "UNSAFE_className": "harry"}, + ) + # Test with a function reversing the keys + self.assertDictEqual( + dict_to_camel_case( + {"foo": "fiz", "bar": "biz"}, convert_key=lambda x: x[::-1] + ), + {"oof": "fiz", "rab": "biz"}, + ) def test_remove_empty_keys(self): from deephaven.ui._internal.utils import remove_empty_keys