Skip to content

feat: Modal Selects #2858

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

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
827524e
types
NeloBlivion Aug 6, 2025
48e1557
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2025
dcdea3f
component
NeloBlivion Aug 6, 2025
6e68e6a
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2025
4686ea0
items
NeloBlivion Aug 6, 2025
be6f1c0
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 6, 2025
5dd4026
Merge branch 'master' into modal_select
Lulalaby Aug 6, 2025
e76fd92
interaction._raw_data for testing
NeloBlivion Aug 6, 2025
20f1096
append
NeloBlivion Aug 7, 2025
e1c3760
move required around
NeloBlivion Aug 7, 2025
cf33b2b
raw
NeloBlivion Aug 7, 2025
132a63f
req?
NeloBlivion Aug 7, 2025
bf614aa
default required none
NeloBlivion Aug 7, 2025
20b39bf
select always uses labelcomponent
NeloBlivion Aug 7, 2025
24f018b
labels
NeloBlivion Aug 7, 2025
7dd44f5
store
NeloBlivion Aug 7, 2025
4be7d14
:=
NeloBlivion Aug 7, 2025
2c3ab0d
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 7, 2025
9a75635
again
NeloBlivion Aug 7, 2025
0ddc5d2
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 7, 2025
68ecdd4
refresh_state
NeloBlivion Aug 7, 2025
a54c449
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 7, 2025
5b53a3a
docs and limits; unsure on legacy textinput label limit
NeloBlivion Aug 7, 2025
e70b067
uses_label
NeloBlivion Aug 7, 2025
d9e6939
update limits
NeloBlivion Aug 7, 2025
db08582
Merge branch 'master' into modal_select
NeloBlivion Aug 7, 2025
a1ded7e
Update discord/ui/select.py
Lulalaby Aug 7, 2025
4455bda
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 7, 2025
0cf4938
v
NeloBlivion Aug 7, 2025
ac8b012
remove clarification
NeloBlivion Aug 7, 2025
f6b0d59
true
NeloBlivion Aug 8, 2025
64f2406
required check
NeloBlivion Aug 8, 2025
2332b87
Merge branch 'master' into modal_select
NeloBlivion Aug 12, 2025
18cf7fa
cl
NeloBlivion Aug 12, 2025
9e29a09
Update CHANGELOG.md
NeloBlivion Aug 12, 2025
7dfe5c2
Apply suggestions from code review
Lulalaby Aug 12, 2025
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ These changes are available on the `master` branch, but have not yet been releas
([#2818](https://github.com/Pycord-Development/pycord/pull/2818))
- Added `Interaction.attachment_size_limit`.
([#2854](https://github.com/Pycord-Development/pycord/pull/2854))
- Added support for string selects in modals.
([#2858](https://github.com/Pycord-Development/pycord/pull/2858))

### Fixed

Expand Down
64 changes: 64 additions & 0 deletions discord/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from .types.components import ContainerComponent as ContainerComponentPayload
from .types.components import FileComponent as FileComponentPayload
from .types.components import InputText as InputTextComponentPayload
from .types.components import LabelComponent as LabelComponentPayload
from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload
from .types.components import MediaGalleryItem as MediaGalleryItemPayload
from .types.components import SectionComponent as SectionComponentPayload
Expand All @@ -76,6 +77,7 @@
"FileComponent",
"Separator",
"Container",
"Label",
)

C = TypeVar("C", bound="Component")
Expand Down Expand Up @@ -375,6 +377,10 @@ class SelectMenu(Component):
Added support for :attr:`ComponentType.user_select`, :attr:`ComponentType.role_select`,
:attr:`ComponentType.mentionable_select`, and :attr:`ComponentType.channel_select`.

.. versionchanged:: 2.7

Added the :attr:`required` attribute for use in modals.

Attributes
----------
type: :class:`ComponentType`
Expand All @@ -399,6 +405,8 @@ class SelectMenu(Component):
except for :attr:`ComponentType.channel_select`.
disabled: :class:`bool`
Whether the select is disabled or not.
required: Optional[:class:`bool`]
Whether the select is required or not. Only useable in modals. Defaults to ``False``.
"""

__slots__: tuple[str, ...] = (
Expand All @@ -409,6 +417,7 @@ class SelectMenu(Component):
"options",
"channel_types",
"disabled",
"required",
)

__repr_info__: ClassVar[tuple[str, ...]] = __slots__
Expand All @@ -428,6 +437,9 @@ def __init__(self, data: SelectMenuPayload):
self.channel_types: list[ChannelType] = [
try_enum(ChannelType, ct) for ct in data.get("channel_types", [])
]
self.required: bool | None = data.get(
"required"
) # Currently defaults to False, pending change
Copy link
Member

Choose a reason for hiding this comment

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

Pending change?

Also why Optional[bool] defaulting to None instead of bool defaulting to False?

Copy link
Member

Choose a reason for hiding this comment

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

because required is only for string selects in modals. it doesn't work in message components

Copy link
Member

Choose a reason for hiding this comment

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

and pending cause advaith is still working on stuff


def to_dict(self) -> SelectMenuPayload:
payload: SelectMenuPayload = {
Expand All @@ -445,6 +457,8 @@ def to_dict(self) -> SelectMenuPayload:
payload["channel_types"] = [ct.value for ct in self.channel_types]
if self.placeholder:
payload["placeholder"] = self.placeholder
if self.required is not None:
payload["required"] = self.required

return payload

Expand Down Expand Up @@ -1037,6 +1051,55 @@ def walk_components(self) -> Iterator[Component]:
yield c


class Label(Component):
"""Represents a Label used in modals as the top-level component.

This is a component that holda another component alongside additional text in modals.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
This is a component that holda another component alongside additional text in modals.
This is a component that allows you to add additional text to another component.

Copy link
Member

Choose a reason for hiding this comment

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

No plsplspls keep that one lol

``component`` may only be:

- :class:`InputText`
- :class:`SelectMenu` (string)

This inherits from :class:`Component`.

.. versionadded:: 2.7

Attributes
----------
component: :class:`Component`
The component contained in this label. Currently supports :class:`InputText` and :class:`SelectMenu`.
label: :class:`str`
The main text associated with this label's ``component``, up to 45 characters.
description: Optional[:class:`str`]
The description associated with this label's ``component``, up to 100 characters.
"""

__slots__: tuple[str, ...] = ("component", "label", "description")

__repr_info__: ClassVar[tuple[str, ...]] = __slots__
versions: tuple[int, ...] = ()

def __init__(self, data: LabelComponentPayload):
self.type: ComponentType = try_enum(ComponentType, data["type"])
self.id: int = data["id"]
self.component: Component = _component_factory(data["component"])
self.label: str = data["label"]
self.description: str | None = data.get("description")

def to_dict(self) -> LabelComponentPayload:
payload = {
"type": int(self.type),
"id": self.id,
"component": self.component.to_dict(),
"label": self.label,
"description": self.description,
}
return payload

def walk_components(self) -> Iterator[Component]:
yield from [self.component]


COMPONENT_MAPPINGS = {
1: ActionRow,
2: Button,
Expand All @@ -1053,6 +1116,7 @@ def walk_components(self) -> Iterator[Component]:
13: FileComponent,
14: Separator,
17: Container,
18: Label,
}

STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent)
Expand Down
2 changes: 2 additions & 0 deletions discord/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ class Interaction:
"view",
"modal",
"attachment_size_limit",
"_raw_data",
"_channel_data",
"_message_data",
"_guild_data",
Expand All @@ -215,6 +216,7 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState):
self._from_data(data)

def _from_data(self, data: InteractionPayload):
self._raw_data: InteractionPayload = data
Copy link
Member

Choose a reason for hiding this comment

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

tbf we might want to pr that to keep it not just for testing lol

Copy link
Member Author

Choose a reason for hiding this comment

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

pls

self.id: int = int(data["id"])
self.type: InteractionType = try_enum(InteractionType, data["type"])
self.data: InteractionData | None = data.get("data")
Expand Down
12 changes: 10 additions & 2 deletions discord/types/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from .emoji import PartialEmoji
from .snowflake import Snowflake

ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17]
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
InputTextStyle = Literal[1, 2]
SeparatorSpacingSize = Literal[1, 2]
Expand Down Expand Up @@ -69,7 +69,7 @@ class InputText(BaseComponent):
type: Literal[4]
style: InputTextStyle
custom_id: str
label: str
label: NotRequired[str]


class SelectOption(TypedDict):
Expand All @@ -89,6 +89,7 @@ class SelectMenu(BaseComponent):
options: NotRequired[list[SelectOption]]
type: Literal[3, 5, 6, 7, 8]
custom_id: str
required: NotRequired[bool]


class TextDisplayComponent(BaseComponent):
Expand Down Expand Up @@ -151,6 +152,13 @@ class ContainerComponent(BaseComponent):
components: list[AllowedContainerComponents]


class LabelComponent(BaseComponent):
type: Literal[18]
label: str
description: NotRequired[str]
component: SelectMenu | InputText


Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText]


Expand Down
13 changes: 13 additions & 0 deletions discord/ui/input_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class InputText:
label: :class:`str`
The label for the input text field.
Must be 45 characters or fewer.
description: Optional[:class:`str`]
The description for the input text field.
Must be 100 characters or fewer.

.. versionadded:: 2.7
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
Must be 100 characters or fewer.
Expand Down Expand Up @@ -58,6 +63,7 @@ class InputText:
"max_length",
"custom_id",
"id",
"description",
)

def __init__(
Expand All @@ -73,10 +79,13 @@ def __init__(
value: str | None = None,
row: int | None = None,
id: int | None = None,
description: str | None = None,
):
super().__init__()
if len(str(label)) > 45:
raise ValueError("label must be 45 characters or fewer")
if description and len(description) > 100:
raise ValueError("description must be 100 characters or fewer")
if min_length and (min_length < 0 or min_length > 4000):
raise ValueError("min_length must be between 0 and 4000")
if max_length and (max_length < 0 or max_length > 4000):
Expand All @@ -90,6 +99,7 @@ def __init__(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self.description: str | None = description

self._underlying = InputTextComponent._raw_construct(
type=ComponentType.input_text,
Expand Down Expand Up @@ -236,3 +246,6 @@ def to_component_dict(self) -> InputTextComponentPayload:

def refresh_state(self, data) -> None:
self._input_value = data["value"]

def uses_label(self) -> bool:
return self.description is not None
3 changes: 3 additions & 0 deletions discord/ui/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ def is_storable(self) -> bool:
def is_persistent(self) -> bool:
return not self.is_dispatchable() or self._provided_custom_id

def uses_label(self) -> bool:
return False

def copy_text(self) -> str:
return ""

Expand Down
Loading
Loading