From 3fb627d0785885e6560f979f888603751ac9465a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 14 Aug 2025 00:27:47 -0400 Subject: [PATCH] Add support for label components and select in modals --- discord/components.py | 70 ++++++++++++++++- discord/enums.py | 1 + discord/types/components.py | 14 +++- discord/types/interactions.py | 17 ++++- discord/ui/__init__.py | 1 + discord/ui/label.py | 140 ++++++++++++++++++++++++++++++++++ discord/ui/modal.py | 27 ++++++- discord/ui/select.py | 20 +++++ discord/ui/text_input.py | 18 +++-- discord/ui/view.py | 5 ++ docs/interactions/api.rst | 26 ++++++- 11 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 discord/ui/label.py diff --git a/discord/components.py b/discord/components.py index 00e5db015..0c35a2fea 100644 --- a/discord/components.py +++ b/discord/components.py @@ -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) diff --git a/discord/enums.py b/discord/enums.py index b25c221a8..6e6242145 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -677,6 +677,7 @@ class ComponentType(Enum): file = 13 separator = 14 container = 17 + label = 18 def __int__(self) -> int: return self.value diff --git a/discord/types/components.py b/discord/types/components.py index 189122bae..bb75a918f 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -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] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index dc9971a1f..6b6e352a4 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -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): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 4d613f14f..2ce3655ed 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -24,3 +24,4 @@ from .separator import * from .text_display import * from .thumbnail import * from .action_row import * +from .label import * diff --git a/discord/ui/label.py b/discord/ui/label.py new file mode 100644 index 000000000..9357de425 --- /dev/null +++ b/discord/ui/label.py @@ -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 diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 630fc20f0..4e6d0eb22 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -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: diff --git a/discord/ui/select.py b/discord/ui/select.py index 8a8c39993..b2db0e10e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -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, diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 218d7c4d0..288e5efdc 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -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 diff --git a/discord/ui/view.py b/discord/ui/view.py index 105a7b1d8..57e036864 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -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) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 40741768d..7e6bd53df 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -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 ~~~~~~~~~~~~