Skip to content

Commit

Permalink
feat: Add accessibility props to action_button (#248)
Browse files Browse the repository at this point in the history
- Also fix how the name is retrieved from the element, and include the
event type on keyboard events
- Tested using the examples added
  • Loading branch information
mofojed authored Feb 1, 2024
1 parent 38b202f commit 39cf7db
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 34 deletions.
39 changes: 39 additions & 0 deletions plugins/ui/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ def form_submit_example():
fs = form_submit_example()
```

## Events

Included with events are many details about the action itself, e.g. modifier keys held down, or the name of the target element. In this example, we create a custom component that prints all press, key, and focus events to the console, and add two of them to a panel to show interaction with both of them (e.g. when focus switches from one button to another):

```python
import deephaven.ui as ui


@ui.component
def button_event_printer(*children, id="My Button"):
return ui.action_button(
*children,
on_key_down=print,
on_key_up=print,
on_press=print,
on_press_start=print,
on_press_end=print,
on_press_change=lambda is_pressed: print(f"{id} is_pressed: {is_pressed}"),
on_press_up=print,
on_focus=print,
on_blur=print,
on_focus_change=lambda is_focused: print(f"{id} is_focused: {is_focused}"),
id=id,
)


@ui.component
def button_events():
return [
button_event_printer("1", id="My Button 1"),
button_event_printer("2", id="My Button 2"),
]


be = button_events()
```

![Events](assets/events.png)

# Data Examples

Many of the examples below use the stocks table provided by `deephaven.plot.express` package:
Expand Down
Binary file added plugins/ui/examples/assets/events.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 10 additions & 4 deletions plugins/ui/src/deephaven/ui/_internal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import Any, Callable

_UNSAFE_PREFIX = "UNSAFE_"
_ARIA_PREFIX = "aria_"
_ARIA_PREFIX_REPLACEMENT = "aria-"


def get_component_name(component: Any) -> str:
Expand Down Expand Up @@ -51,25 +53,29 @@ def to_camel_case(snake_case_text: str) -> str:
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:
def to_react_prop_case(snake_case_text: str) -> str:
"""
Convert a snake_case string to camelCase. Leaves the `UNSAFE_` prefix intact if present.
Convert a snake_case string to camelCase, with exceptions for special props like `UNSAFE_` or `aria_` props.
Args:
snake_case_text: The snake_case string to convert.
Returns:
The camelCase string with the `UNSAFE_` prefix intact if present.
The camelCase string with the `UNSAFE_` prefix intact if present, or `aria_` converted to `aria-`.
"""
if snake_case_text.startswith(_UNSAFE_PREFIX):
return _UNSAFE_PREFIX + to_camel_case(snake_case_text[len(_UNSAFE_PREFIX) :])
if snake_case_text.startswith(_ARIA_PREFIX):
return _ARIA_PREFIX_REPLACEMENT + to_camel_case(
snake_case_text[len(_ARIA_PREFIX) :]
)
return to_camel_case(snake_case_text)


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,
convert_key: Callable[[str], str] = to_react_prop_case,
) -> dict[str, Any]:
"""
Convert a dict with snake_case keys to a dict with camelCase keys.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Literal, Union

# Accessibility typings

BoolLiteral = Union[Literal["true", "false"], bool]
AriaExpanded = BoolLiteral
AriaHasPopup = Union[BoolLiteral, Literal["menu", "listbox", "tree", "grid", "dialog"]]
AriaPressed = Union[BoolLiteral, Literal["mixed"]]
31 changes: 31 additions & 0 deletions plugins/ui/src/deephaven/ui/components/spectrum/action_button.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Any, Callable
from .accessibility import AriaExpanded, AriaHasPopup, AriaPressed
from .events import (
ButtonType,
FocusEventCallable,
Expand Down Expand Up @@ -73,6 +74,16 @@ def action_button(
right: DimensionValue | None = None,
z_index: Number | None = None,
is_hidden: bool | None = None,
id: str | None = None,
exclude_from_tab_order: bool | None = None,
aria_expanded: AriaExpanded | None = None,
aria_haspopup: AriaHasPopup | None = None,
aria_controls: 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,
) -> Element:
Expand Down Expand Up @@ -133,6 +144,16 @@ def action_button(
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.
exclude_from_tab_order: Whether the element should be excluded from the tab order.
aria_expanded: Whether the element is expanded.
aria_haspopup: Whether the element has a popup.
aria_controls: The id of the element that the element controls.
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.
"""
Expand Down Expand Up @@ -190,6 +211,16 @@ def action_button(
right=right,
z_index=z_index,
is_hidden=is_hidden,
id=id,
exclude_from_tab_order=exclude_from_tab_order,
aria_expanded=aria_expanded,
aria_haspopup=aria_haspopup,
aria_controls=aria_controls,
aria_label=aria_label,
aria_labelledby=aria_labelledby,
aria_describedby=aria_describedby,
aria_pressed=aria_pressed,
aria_details=aria_details,
UNSAFE_class_name=UNSAFE_class_name,
UNSAFE_style=UNSAFE_style,
)
36 changes: 36 additions & 0 deletions plugins/ui/src/js/src/spectrum/EventUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getTargetName } from './EventUtils';

describe('getTargetName', () => {
it('should return the name attribute if it exists', () => {
const target = document.createElement('div');
target.setAttribute('name', 'test-name');
expect(getTargetName(target)).toBe('test-name');
});

it('should return the name if both name and id exist', () => {
const target = document.createElement('div');
target.setAttribute('name', 'test-name');
target.setAttribute('id', 'test-id');
expect(getTargetName(target)).toBe('test-name');
});

it('should return the id attribute if the name attribute does not exist', () => {
const target = document.createElement('div');
target.setAttribute('id', 'test-id');
expect(getTargetName(target)).toBe('test-id');
});

it('should return undefined if neither the name nor id attribute exists', () => {
const target = document.createElement('div');
expect(getTargetName(target)).toBeUndefined();
});

it('should return undefined if the target is null', () => {
expect(getTargetName(null)).toBeUndefined();
});

it('should return undefined if the target is not an Element', () => {
const target = {} as EventTarget;
expect(getTargetName(target)).toBeUndefined();
});
});
10 changes: 10 additions & 0 deletions plugins/ui/src/js/src/spectrum/EventUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function getTargetName(target: EventTarget | null): string | undefined {
if (target instanceof Element) {
return (
target.getAttribute('name') ?? target.getAttribute('id') ?? undefined
);
}
return undefined;
}

export default getTargetName;
9 changes: 3 additions & 6 deletions plugins/ui/src/js/src/spectrum/useFocusEventCallback.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { FocusEvent, useCallback } from 'react';
import getTargetName from './EventUtils';

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;
const targetName = getTargetName(target);
const relatedTargetName = getTargetName(relatedTarget);
return {
type,
target: targetName ?? undefined,
Expand Down
13 changes: 10 additions & 3 deletions plugins/ui/src/js/src/spectrum/useKeyboardEventCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ 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 };
const { code, key, shiftKey, ctrlKey, metaKey, altKey, repeat, type } = event;
return { code, key, shiftKey, ctrlKey, metaKey, altKey, repeat, type };
}

/**
* KeyboardEvent serialized so it can be sent to the server.
*/
export type SerializedKeyboardEvent = Pick<
KeyboardEvent,
'code' | 'key' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'altKey' | 'repeat'
| 'code'
| 'key'
| 'shiftKey'
| 'ctrlKey'
| 'metaKey'
| 'altKey'
| 'repeat'
| 'type'
>;

export type SerializedKeyboardEventCallback = (
Expand Down
5 changes: 3 additions & 2 deletions plugins/ui/src/js/src/spectrum/usePressEventCallback.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { PressEvent } from '@react-types/shared';
import { useCallback } from 'react';
import { PressEvent } from '@react-types/shared';
import getTargetName from './EventUtils';

export function serializePressEvent(event: PressEvent): SerializedPressEvent {
const { target, type, pointerType, shiftKey, ctrlKey, metaKey, altKey } =
event;
return {
target: target?.getAttribute('name') ?? undefined,
target: getTargetName(target),
type,
pointerType,
shiftKey,
Expand Down
34 changes: 15 additions & 19 deletions plugins/ui/test/deephaven/ui/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,22 @@ def test_to_camel_case(self):
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
def test_to_react_prop_case(self):
from deephaven.ui._internal.utils import to_react_prop_case

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")
self.assertEqual(to_react_prop_case("test_string"), "testString")
self.assertEqual(to_react_prop_case("test_string_2"), "testString2")
self.assertEqual(to_react_prop_case("align_items"), "alignItems")
self.assertEqual(to_react_prop_case("First_Word"), "FirstWord")
self.assertEqual(to_react_prop_case("first_word"), "firstWord")
self.assertEqual(to_react_prop_case("alreadyCamelCase"), "alreadyCamelCase")
self.assertEqual(to_react_prop_case(""), "")
self.assertEqual(to_react_prop_case("UNSAFE_style"), "UNSAFE_style")
self.assertEqual(to_react_prop_case("UNSAFE_class_name"), "UNSAFE_className")
self.assertEqual(to_react_prop_case("UNSAFE_className"), "UNSAFE_className")
self.assertEqual(to_react_prop_case("unsafe_style"), "unsafeStyle")
self.assertEqual(to_react_prop_case("aria_expanded"), "aria-expanded")
self.assertEqual(to_react_prop_case("aria_labelledby"), "aria-labelledby")

def test_dict_to_camel_case(self):
from deephaven.ui._internal.utils import dict_to_camel_case
Expand Down

0 comments on commit 39cf7db

Please sign in to comment.