Add support for label components and select in modals

This commit is contained in:
Rapptz 2025-08-14 00:27:47 -04:00
parent 9fb74fd7a1
commit 3fb627d078
11 changed files with 325 additions and 14 deletions

View File

@ -70,6 +70,7 @@ if TYPE_CHECKING:
ThumbnailComponent as ThumbnailComponentPayload,
ContainerComponent as ContainerComponentPayload,
UnfurledMediaItem as UnfurledMediaItemPayload,
LabelComponent as LabelComponentPayload,
)
from .emoji import Emoji
@ -109,6 +110,7 @@ __all__ = (
'Container',
'TextDisplay',
'SeparatorComponent',
'LabelComponent',
)
@ -348,6 +350,10 @@ class SelectMenu(Component):
id: Optional[:class:`int`]
The ID of this component.
.. versionadded:: 2.6
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
"""
@ -361,6 +367,7 @@ class SelectMenu(Component):
'disabled',
'channel_types',
'default_values',
'required',
'id',
)
@ -372,6 +379,7 @@ class SelectMenu(Component):
self.placeholder: Optional[str] = data.get('placeholder')
self.min_values: int = data.get('min_values', 1)
self.max_values: int = data.get('max_values', 1)
self.required: bool = data.get('required', False)
self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])]
self.disabled: bool = data.get('disabled', False)
self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])]
@ -544,7 +552,7 @@ class TextInput(Component):
------------
custom_id: Optional[:class:`str`]
The ID of the text input that gets received during an interaction.
label: :class:`str`
label: Optional[:class:`str`]
The label to display above the text input.
style: :class:`TextStyle`
The style of the text input.
@ -580,7 +588,7 @@ class TextInput(Component):
def __init__(self, data: TextInputPayload, /) -> None:
self.style: TextStyle = try_enum(TextStyle, data['style'])
self.label: str = data['label']
self.label: Optional[str] = data.get('label')
self.custom_id: str = data['custom_id']
self.placeholder: Optional[str] = data.get('placeholder')
self.value: Optional[str] = data.get('value')
@ -1309,6 +1317,62 @@ class Container(Component):
return payload
class LabelComponent(Component):
"""Represents a label component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a label is
:class:`discord.ui.Label` not this one.
.. versionadded:: 2.6
Attributes
----------
label: :class:`str`
The label text to display.
description: Optional[:class:`str`]
The description text to display below the label, if any.
component: :class:`Component`
The component that this label is associated with.
id: Optional[:class:`int`]
The ID of this component.
"""
__slots__ = (
'label',
'description',
'commponent',
'id',
)
__repr_info__ = ('label', 'description', 'commponent', 'id,')
def __init__(self, data: LabelComponentPayload, state: Optional[ConnectionState]) -> None:
self.component: Component = _component_factory(data['component'], state) # type: ignore
self.label: str = data['label']
self.id: Optional[int] = data.get('id')
self.description: Optional[str] = data.get('description')
@property
def type(self) -> Literal[ComponentType.label]:
return ComponentType.label
def to_dict(self) -> LabelComponentPayload:
payload: LabelComponentPayload = {
'type': self.type.value,
'label': self.label,
'component': self.component.to_dict(), # type: ignore
}
if self.description:
payload['description'] = self.description
if self.id is not None:
payload['id'] = self.id
return payload
def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1:
return ActionRow(data)
@ -1332,3 +1396,5 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState]
return SeparatorComponent(data)
elif data['type'] == 17:
return Container(data, state)
elif data['type'] == 18:
return LabelComponent(data, state)

View File

@ -677,6 +677,7 @@ class ComponentType(Enum):
file = 13
separator = 14
container = 17
label = 18
def __int__(self) -> int:
return self.value

View File

@ -30,7 +30,7 @@ from typing_extensions import NotRequired
from .emoji import PartialEmoji
from .channel import ChannelType
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17]
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextStyle = Literal[1, 2]
DefaultValueType = Literal['user', 'role', 'channel']
@ -110,7 +110,7 @@ class TextInput(ComponentBase):
type: Literal[4]
custom_id: str
style: TextStyle
label: str
label: Optional[str]
placeholder: NotRequired[str]
value: NotRequired[str]
required: NotRequired[bool]
@ -120,6 +120,7 @@ class TextInput(ComponentBase):
class SelectMenu(SelectComponent):
type: Literal[3, 5, 6, 7, 8]
required: NotRequired[bool] # Only for StringSelect within modals
options: NotRequired[List[SelectOption]]
channel_types: NotRequired[List[ChannelType]]
default_values: NotRequired[List[SelectDefaultValues]]
@ -187,6 +188,13 @@ class ContainerComponent(ComponentBase):
components: List[ContainerChildComponent]
class LabelComponent(ComponentBase):
type: Literal[18]
label: str
description: NotRequired[str]
component: Union[StringSelectComponent, TextInput]
ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput]
ContainerChildComponent = Union[
ActionRow,
@ -199,4 +207,4 @@ ContainerChildComponent = Union[
SeparatorComponent,
ThumbnailComponent,
]
Component = Union[ActionRowChildComponent, ContainerChildComponent]
Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent]

View File

@ -209,7 +209,13 @@ class ModalSubmitTextInputInteractionData(TypedDict):
value: str
ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData
class ModalSubmitStringSelectInteractionData(TypedDict):
type: Literal[3]
custom_id: str
values: List[str]
ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData]
class ModalSubmitActionRowInteractionData(TypedDict):
@ -217,7 +223,14 @@ class ModalSubmitActionRowInteractionData(TypedDict):
components: List[ModalSubmitComponentItemInteractionData]
ModalSubmitComponentInteractionData = Union[ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData]
class ModalSubmitLabelInteractionData(TypedDict):
type: Literal[18]
component: ModalSubmitComponentItemInteractionData
ModalSubmitComponentInteractionData = Union[
ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData
]
class ModalSubmitInteractionData(TypedDict):

View File

@ -24,3 +24,4 @@ from .separator import *
from .text_display import *
from .thumbnail import *
from .action_row import *
from .label import *

140
discord/ui/label.py Normal file
View File

@ -0,0 +1,140 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Generator, Literal, Optional, Tuple, TypeVar
from ..components import LabelComponent
from ..enums import ComponentType
from ..utils import MISSING
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import LabelComponent as LabelComponentPayload
from .view import View
# fmt: off
__all__ = (
'Label',
)
# fmt: on
V = TypeVar('V', bound='View', covariant=True)
class Label(Item[V]):
"""Represents a UI label within a modal.
.. versionadded:: 2.6
Parameters
------------
text: :class:`str`
The text to display above the input field.
Can only be up to 45 characters.
description: Optional[:class:`str`]
The description text to display right below the label text.
Can only be up to 100 characters.
component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`]
The component to display below the label.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
Attributes
------------
text: :class:`str`
The text to display above the input field.
Can only be up to 45 characters.
description: Optional[:class:`str`]
The description text to display right below the label text.
Can only be up to 100 characters.
component: :class:`Item`
The component to display below the label. Currently only
supports :class:`TextInput` and :class:`Select`.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'text',
'description',
'component',
)
def __init__(
self,
*,
text: str,
component: Item[V],
description: Optional[str] = None,
id: Optional[int] = None,
) -> None:
super().__init__()
self.component: Item[V] = component
self.text: str = text
self.description: Optional[str] = description
self.id = id
@property
def width(self) -> int:
return 5
def _has_children(self) -> bool:
return True
def walk_children(self) -> Generator[Item[V], None, None]:
yield self.component
def to_component_dict(self) -> LabelComponentPayload:
payload: LabelComponentPayload = {
'type': ComponentType.label.value,
'label': self.text,
'component': self.component.to_component_dict(), # type: ignore
}
if self.description:
payload['description'] = self.description
if self.id is not None:
payload['id'] = self.id
return payload
@classmethod
def from_component(cls, component: LabelComponent) -> Self:
from .view import _component_to_item
self = cls(
text=component.label,
component=MISSING,
description=component.description,
)
self.component = _component_to_item(component.component, self)
return self
@property
def type(self) -> Literal[ComponentType.label]:
return ComponentType.label
def is_dispatchable(self) -> bool:
return False

View File

@ -34,6 +34,7 @@ from ..utils import MISSING, find
from .._types import ClientT
from .item import Item
from .view import View
from .label import Label
if TYPE_CHECKING:
from typing_extensions import Self
@ -170,8 +171,10 @@ class Modal(View):
for component in components:
if component['type'] == 1:
self._refresh(interaction, component['components'])
elif component['type'] == 18:
self._refresh(interaction, [component['component']])
else:
item = find(lambda i: i.custom_id == component['custom_id'], self._children) # type: ignore
item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore
if item is None:
_log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id'])
continue
@ -194,6 +197,28 @@ class Modal(View):
# In the future, maybe this will require checking if we set an error response.
self.stop()
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
return item._rendered_row or 0
children = sorted(self._children, key=key)
components: List[Dict[str, Any]] = []
for child in children:
if isinstance(child, Label):
components.append(child.to_component_dict()) # type: ignore
else:
# Every implicit child wrapped in an ActionRow in a modal
# has a single child of width 5
# It's also deprecated to use ActionRow in modals
components.append(
{
'type': 1,
'components': [child.to_component_dict()],
}
)
return components
def _dispatch_submit(
self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]
) -> None:

View File

@ -239,6 +239,7 @@ class BaseSelect(Item[V]):
min_values: Optional[int] = None,
max_values: Optional[int] = None,
disabled: bool = False,
required: bool = False,
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING,
default_values: Sequence[SelectDefaultValue] = MISSING,
@ -257,6 +258,7 @@ class BaseSelect(Item[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
channel_types=[] if channel_types is MISSING else channel_types,
options=[] if options is MISSING else options,
default_values=[] if default_values is MISSING else default_values,
@ -332,6 +334,18 @@ class BaseSelect(Item[V]):
def disabled(self, value: bool) -> None:
self._underlying.disabled = bool(value)
@property
def required(self) -> bool:
""":class:`bool`: Whether the select is required or not. Only supported in modals.
.. versionadded:: 2.6
"""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = bool(value)
@property
def width(self) -> int:
return 5
@ -399,6 +413,10 @@ class Select(BaseSelect[V]):
Can only contain up to 25 items.
disabled: :class:`bool`
Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
row: Optional[:class:`int`]
The relative row this select menu belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
@ -426,6 +444,7 @@ class Select(BaseSelect[V]):
max_values: int = 1,
options: List[SelectOption] = MISSING,
disabled: bool = False,
required: bool = True,
row: Optional[int] = None,
id: Optional[int] = None,
) -> None:
@ -436,6 +455,7 @@ class Select(BaseSelect[V]):
min_values=min_values,
max_values=max_values,
disabled=disabled,
required=required,
options=options,
row=row,
id=id,

View File

@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Literal, Optional, Tuple, TypeVar
from ..components import TextInput as TextInputComponent
from ..enums import ComponentType, TextStyle
from ..utils import MISSING
from ..utils import MISSING, deprecated
from .item import Item
if TYPE_CHECKING:
@ -63,9 +63,15 @@ class TextInput(Item[V]):
Parameters
------------
label: :class:`str`
label: Optional[:class:`str`]
The label to display above the text input.
Can only be up to 45 characters.
.. deprecated:: 2.6
This parameter is deprecated, use :class:`discord.ui.Label` instead.
.. versionchanged:: 2.6
This parameter is now optional and defaults to ``None``.
custom_id: :class:`str`
The ID of the text input that gets received during an interaction.
If not given then one is generated for you.
@ -108,7 +114,7 @@ class TextInput(Item[V]):
def __init__(
self,
*,
label: str,
label: Optional[str] = None,
style: TextStyle = TextStyle.short,
custom_id: str = MISSING,
placeholder: Optional[str] = None,
@ -166,12 +172,14 @@ class TextInput(Item[V]):
return self._value or ''
@property
def label(self) -> str:
@deprecated('discord.ui.Label')
def label(self) -> Optional[str]:
""":class:`str`: The label of the text input."""
return self._underlying.label
@label.setter
def label(self, value: str) -> None:
@deprecated('discord.ui.Label')
def label(self, value: Optional[str]) -> None:
self._underlying.label = value
@property

View File

@ -65,6 +65,7 @@ from ..components import (
SeparatorComponent,
ThumbnailComponent,
Container as ContainerComponent,
LabelComponent,
)
from ..utils import get as _utils_get, find as _utils_find
@ -147,6 +148,10 @@ def _component_to_item(component: Component, parent: Optional[Item] = None) -> I
from .container import Container
item = Container.from_component(component)
elif isinstance(component, LabelComponent):
from .label import Label
item = Label.from_component(component)
else:
item = Item.from_component(component)

View File

@ -113,6 +113,15 @@ TextInput
:members:
:inherited-members:
LabelComponent
~~~~~~~~~~~~~~~~
.. attributetable:: LabelComponent
.. autoclass:: LabelComponent()
:members:
:inherited-members:
SectionComponent
~~~~~~~~~~~~~~~~
@ -425,7 +434,7 @@ Enumerations
.. attribute:: media_gallery
Represents a media gallery component.
.. versionadded:: 2.6
.. attribute:: file
@ -446,6 +455,12 @@ Enumerations
.. versionadded:: 2.6
.. attribute:: label
Represents a label container component, usually in a modal.
.. versionadded:: 2.6
.. class:: ButtonStyle
Represents the style of the button component.
@ -742,6 +757,15 @@ File
:members:
:inherited-members:
Label
~~~~~~
.. attributetable:: discord.ui.Label
.. autoclass:: discord.ui.Label
:members:
:inherited-members:
MediaGallery
~~~~~~~~~~~~