Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

html: add radio buttons #435

Merged
merged 4 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SHELL=/bin/bash
PYTHON=python3.10
PYTHON=python3.11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be part of this PR?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily, but it isn't a problem either because it is a separate patch. While implementing this, I ran into some issues and wanted to use the new exception pretty printing of Python 3.11.

PYTHON_ENV=env

.PHONY: clean doc dist build test ci-test lint isort shell freeze
Expand Down
67 changes: 67 additions & 0 deletions doc/content/api-reference/html.rst
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,73 @@ select is no multi select, all other options get unselected automatically.
|style |(Dict) contains all styling attributes


RadioGroup and RadioButton
++++++++++++++++++++++++++

.. code-block:: python

from lona.html import RadioGroup, RadioButton, Label

radio_group = RadioGroup(
Label('Option 1', RadioButton(value=1)),
Label('Option 2', RadioButton(value=2.0)),
Label('Option 3', RadioButton(value='3', checked=True)),
)

# adding radio buttons
# `RadioGroup.add_button()` takes any amount of nodes and connects
# the first `Label` with the first `RadioButton` object, using a
# random number and the HTML attribute `for`
radio_group.add_button(Label('Foo'), RadioButton(value='foo'))
radio_group.add_button(Div(Label('Foo'), RadioButton(value='foo')))

# if two non-node values are given, and the first one is a string,
# a `Label` and a `RadioButton` get inserted automatically
radio_group.add_button('Foo', 'foo')

A ``RadioGroup`` consist of one or more ``RadioButton`` and ``Label`` object
pairs, which hold information on value, checked state, and disabled state.

``RadioButton`` objects consist of a value and a checked state. The value can
be anything. If ``RadioButton.render_value`` is set, which is set by default,
the content of ``RadioButton.value`` gets typecasted to a string and rendered
into the HTML tree. This can be disabled if the actually values of the select
shouldn't be disclosed to end users.

``RadioGroup.value`` returns the value of the radio button that is currently
checked.

A radio button can be checked by setting ``RadioGroup.value`` to the value of
the radio button that should be checked, or by setting ``RadioButton.checked``.

**RadioGroup Attributes:**

.. table::

^Name ^Description
|bubble_up |(Bool) Pass input events further
|radio_buttons |(Tuple) tuple of all radio buttons
|checked_radio_button |(Tuple) tuple of all selected options
|value |Value of the currently checked radio button
|values |(Tuple) tuple of all possible values
|id_list |(List) contains all ids
|class_list |(List) contains all classes
|style |(Dict) contains all styling attributes

**RadioButton Attributes:**

.. table::

^Name ^Description
|name |name of the radio button
|value |value of the radio button
|checked |(Bool) sets checked state
|disabled |(Bool) sets the HTML attribute "disabled"
|id_list |(List) contains all ids
|class_list |(List) contains all classes
|style |(Dict) contains all styling attributes


Adding Javascript And CSS To HTML Nodes
---------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions lona/client/_lona/client/input-events.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export class LonaInputEventHandler {
_get_value(node) {
var value = node.value;

// checkbox
if(node.getAttribute('type') == 'checkbox') {
// checkbox / radiobutton
if(node.type == 'checkbox' || node.type == 'radio') {
value = node.checked;

// select
Expand Down
4 changes: 2 additions & 2 deletions lona/client2/_lona/client2/input-events.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export class LonaInputEventHandler {
_get_value(node) {
var value = node.value;

// checkbox
if(node.getAttribute('type') == 'checkbox') {
// checkbox / radiobutton
if(node.type == 'checkbox' || node.type == 'radio') {
value = node.checked;

// select
Expand Down
1 change: 1 addition & 0 deletions lona/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
Base,
)
from lona.html.nodes.interactive_elements import Summary, Details, Dialog
from lona.html.nodes.forms.radio_button import RadioButton, RadioGroup
from lona.html.nodes.scripting import NoScript, Script, Canvas
from lona.html.nodes.forms.select2 import Select2, Option2
from lona.html.nodes.web_components import Template, Slot
Expand Down
18 changes: 16 additions & 2 deletions lona/html/attribute_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,22 @@ def __setitem__(self, name, value, issuer=None):
issuer=issuer,
)

def __delitem__(self, name):
self.pop(name, None)
def __delitem__(self, name, issuer=None):
with self._node.lock:
if name not in self._attributes:
return

del self._attributes[name]

self._node.document.add_patch(
node_id=self._node.id,
patch_type=self.PATCH_TYPE,
operation=OPERATION.REMOVE,
payload=[
name,
],
issuer=issuer,
)

def __eq__(self, other):
with self._node.lock:
Expand Down
198 changes: 198 additions & 0 deletions lona/html/nodes/forms/radio_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from uuid import uuid1

from lona.html.nodes.forms.inputs import TextInput
from lona.html.nodes.text_content import Div
from lona.html.nodes.forms import Label
from lona.html.node import Node


class RadioButton(TextInput):
INPUT_ATTRIBUTE_NAME = 'checked'

ATTRIBUTES = {
'type': 'radio',
'name': 'radio',
}

def __init__(
self,
*args,
value='',
bubble_up=True,
checked=False,
render_value=True,
**kwargs,
):

self.render_value = render_value

super().__init__(*args, bubble_up=bubble_up, **kwargs)

self.value = value
self.checked = checked

def _render_value(self, value):
return str(value)

# value
@property
def value(self):
return self._value

@value.setter
def value(self, new_value):
with self.lock:
if self.render_value:
self.attributes['value'] = self._render_value(new_value)

self._value = new_value

# name
@property
def name(self):
return self.attributes['name']

@name.setter
def name(self, new_value):
self.attributes['name'] = new_value

# checked
@property
def checked(self):
return 'checked' in self.attributes

@checked.setter
def checked(self, new_value):
if new_value:
self.attributes['checked'] = ''

else:
del self.attributes['checked']


class RadioGroup(Node):
TAG_NAME = 'form'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML5 specification explicitly does not allow nesting of <form>. Even if it feels like there are not many use-cases where one want's to use actual HTML forms in Lona. Why make using them extra hard?

Is there any gain from using form here instead of an element like div that is intended for nesting?


def handle_input_event(self, input_event):

# check if incoming event was fired by a radio button and bubble it
# up if not
if input_event.name != 'change':
return super().handle_input_event(input_event)

if (not input_event.node or
input_event.node.tag_name != 'input' or
input_event.node.attributes.get('type', '') != 'radio'):

return super().handle_input_event(input_event)

# uncheck all radio buttons in the same radio group that are unchecked
# on the client
with self.lock:
name = input_event.node.attributes.get('name', '')

for radio_button in self.radio_buttons:
if radio_button is input_event.node:
continue

if radio_button.attributes.get('name', '') != name:
continue

# The browser unchecks all previously checked radio buttons
# in the same radio group autamatically. So we don't need
# to send a patch to the original issuer of the change event.
if 'checked' in radio_button.attributes:
radio_button.attributes.__delitem__(
'checked',
issuer=(input_event.connection, input_event.window_id),
)

# patch input_event so `input_event.node.value` and `input_event.data`
# yield the actual value of the radio group
input_event.node = self
input_event.data = self.value

return super().handle_input_event(input_event)

@property
def radio_buttons(self):
return tuple(self.query_selector_all('input[type=radio]'))

@property
def checked_radio_button(self):
with self.lock:
for radio_button in self.radio_buttons:
if radio_button.checked:
return radio_button

# value
@property
def value(self):
checked_radio_button = self.checked_radio_button

if not checked_radio_button:
return

return checked_radio_button.value

@value.setter
def value(self, new_value):
with self.lock:
radio_buttons = self.radio_buttons

# check if value is available
if new_value is not None:
values = []

for radio_button in radio_buttons:
values.append(radio_button.value)

if new_value not in values:
raise ValueError(
f"no radio button with value '{new_value}'",
)

# update all radio button checked state
for radio_button in radio_buttons:
radio_button.checked = radio_button.value == new_value

# values
@property
def values(self):
values = []

with self.lock:
for radio_button in self.radio_buttons:
values.append(radio_button.value)

return tuple(values)

def add_button(self, *nodes):

# string value pair
if (len(nodes) == 2 and
isinstance(nodes[0], str) and
not isinstance(nodes[1], Node)):

return self.add_button(
Label(nodes[0]),
RadioButton(value=nodes[1]),
)

# find labels and radio buttons
helper_node = Div(nodes)

labels = helper_node.query_selector_all('label')
buttons = helper_node.query_selector_all('input[type=radio]')

# attach autogenerated id so the labels get clickable in the browser
button_id = f'id_{uuid1().hex}'

for label in labels:
label.attributes['for'] = button_id

for button in buttons:
button.id_list.add(button_id)

# add all nodes to button group
self.nodes.extend(nodes)
2 changes: 1 addition & 1 deletion test_project/Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PYTHON=python3
PYTHON=python3.11
PYTHON_VENV=env

LONA_SHELL_SERVER_URL=file://../socket
Expand Down
22 changes: 21 additions & 1 deletion test_project/views/events/change_events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from pprint import pformat

from lona.html import TextInput, CheckBox, Pre, Div, H2
from lona.html import (
RadioButton,
RadioGroup,
TextInput,
CheckBox,
Label,
Pre,
Div,
H2,
)
from lona.view import View


Expand All @@ -25,6 +34,17 @@ def handle_request(self, request):
bubble_up=True,
),
),
RadioGroup(
Label(
'Option 1',
RadioButton(value='option-1'),
),
Label(
'Option 2',
RadioButton(value='option-2'),
),
bubble_up=True,
),
style={
'float': 'left',
'width': '50%',
Expand Down
Loading