pycascadeui 3.3.4__tar.gz → 3.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. {pycascadeui-3.3.4/pycascadeui.egg-info → pycascadeui-3.4.1}/PKG-INFO +9 -3
  2. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/README.md +8 -2
  3. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/__init__.py +18 -3
  4. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/inputs.py +71 -7
  5. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/patterns/__init__.py +11 -0
  6. pycascadeui-3.4.1/cascadeui/components/patterns/v2.py +2301 -0
  7. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/types.py +9 -0
  8. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/wrappers.py +19 -6
  9. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/devtools.py +28 -5
  10. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/backends/memory.py +12 -0
  11. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/backends/postgres.py +63 -15
  12. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/backends/sqlite.py +57 -17
  13. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/manager.py +251 -58
  14. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/protocols.py +18 -0
  15. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/actions.py +9 -6
  16. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/middleware/logging.py +12 -6
  17. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/middleware/persistence.py +105 -12
  18. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/middleware/undo.py +36 -13
  19. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/store.py +1 -1
  20. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/theming/context.py +19 -0
  21. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/__init__.py +2 -1
  22. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/logging.py +66 -11
  23. pycascadeui-3.4.1/cascadeui/utils/strings.py +72 -0
  24. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/tasks.py +29 -30
  25. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/validation.py +19 -0
  26. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/_interaction.py +48 -3
  27. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/_navigation.py +204 -69
  28. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/_placement.py +59 -0
  29. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/base.py +399 -40
  30. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/layout.py +122 -39
  31. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/form.py +2 -5
  32. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/leaderboard.py +287 -52
  33. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/menu.py +48 -15
  34. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/paginated.py +71 -29
  35. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/roles.py +31 -23
  36. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/tabs.py +15 -2
  37. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/wizard.py +2 -5
  38. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/persistent.py +71 -4
  39. {pycascadeui-3.3.4 → pycascadeui-3.4.1/pycascadeui.egg-info}/PKG-INFO +9 -3
  40. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/pycascadeui.egg-info/SOURCES.txt +2 -0
  41. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/pyproject.toml +1 -1
  42. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_auto_defer.py +35 -0
  43. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_backends.py +83 -0
  44. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_batching.py +17 -17
  45. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_devtools.py +70 -0
  46. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_ephemeral_refresh.py +75 -0
  47. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_inputs.py +172 -9
  48. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_instance_limit.py +23 -2
  49. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_layout_view.py +636 -16
  50. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_leaderboard_patterns.py +459 -11
  51. pycascadeui-3.4.1/tests/test_logging_middleware.py +160 -0
  52. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_menu_patterns.py +59 -9
  53. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_message_cleanup.py +45 -4
  54. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_nav_version.py +2 -2
  55. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_navigation.py +390 -0
  56. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_navigation_artifact_restore.py +21 -0
  57. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_paginated_patterns.py +76 -0
  58. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_persistence.py +825 -4
  59. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_persistence_middleware.py +77 -0
  60. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_persistent_views.py +14 -0
  61. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_placement.py +107 -1
  62. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_rebuild_callback.py +3 -3
  63. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_roles_patterns.py +14 -0
  64. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_state_slots.py +6 -5
  65. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_tab_patterns.py +45 -0
  66. pycascadeui-3.4.1/tests/test_task_manager.py +204 -0
  67. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_theming.py +111 -0
  68. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_undo.py +72 -0
  69. pycascadeui-3.4.1/tests/test_v2_helpers.py +1814 -0
  70. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_validation.py +72 -0
  71. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_view_init.py +91 -0
  72. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_wrappers.py +42 -0
  73. pycascadeui-3.3.4/cascadeui/components/patterns/v2.py +0 -1256
  74. pycascadeui-3.3.4/cascadeui/utils/strings.py +0 -20
  75. pycascadeui-3.3.4/tests/test_v2_helpers.py +0 -672
  76. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/LICENSE +0 -0
  77. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/__main__.py +0 -0
  78. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/__init__.py +0 -0
  79. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/base.py +0 -0
  80. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/buttons.py +0 -0
  81. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/patterns/v1.py +0 -0
  82. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/selects.py +0 -0
  83. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/components/v1_composition.py +0 -0
  84. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/exceptions.py +0 -0
  85. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/__init__.py +0 -0
  86. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/backends/__init__.py +0 -0
  87. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/config.py +0 -0
  88. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/migrations.py +0 -0
  89. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/schema.py +0 -0
  90. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/persistence/schema_postgres.py +0 -0
  91. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/py.typed +0 -0
  92. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/setup.py +0 -0
  93. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/__init__.py +0 -0
  94. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/computed.py +0 -0
  95. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/middleware/__init__.py +0 -0
  96. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/reducers.py +0 -0
  97. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/singleton.py +0 -0
  98. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/slots.py +0 -0
  99. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/state/types.py +0 -0
  100. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/theming/__init__.py +0 -0
  101. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/theming/core.py +0 -0
  102. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/theming/themes.py +0 -0
  103. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/tracing.py +0 -0
  104. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/coercion.py +0 -0
  105. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/decorators.py +0 -0
  106. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/errors.py +0 -0
  107. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/utils/fetch.py +0 -0
  108. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/__init__.py +0 -0
  109. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/__init__.py +0 -0
  110. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/patterns/types.py +0 -0
  111. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/cascadeui/views/view.py +0 -0
  112. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/pycascadeui.egg-info/dependency_links.txt +0 -0
  113. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/pycascadeui.egg-info/requires.txt +0 -0
  114. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/pycascadeui.egg-info/top_level.txt +0 -0
  115. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/setup.cfg +0 -0
  116. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_backends_postgres.py +0 -0
  117. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_backends_raw_sql.py +0 -0
  118. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_button_grid.py +0 -0
  119. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_components.py +0 -0
  120. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_computed.py +0 -0
  121. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_dynamic_persistent_button.py +0 -0
  122. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_emoji_grid.py +0 -0
  123. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_fetch.py +0 -0
  124. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_five_pillars.py +0 -0
  125. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_form_patterns.py +0 -0
  126. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_hooks.py +0 -0
  127. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_input_coercion.py +0 -0
  128. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_layout_pagination.py +0 -0
  129. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_layout_patterns.py +0 -0
  130. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_layout_persistent.py +0 -0
  131. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_middleware.py +0 -0
  132. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_owner_only.py +0 -0
  133. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_pagination.py +0 -0
  134. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_pillar_isolation.py +0 -0
  135. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_reducers.py +0 -0
  136. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_scoping.py +0 -0
  137. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_selects.py +0 -0
  138. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_state_store.py +0 -0
  139. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_update_coalescing.py +0 -0
  140. {pycascadeui-3.3.4 → pycascadeui-3.4.1}/tests/test_wizard_patterns.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.4
3
+ Version: 3.4.1
4
4
  Summary: Redux-inspired UI framework for discord.py
5
5
  Author-email: HollowTheSilver <hollow@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -179,6 +179,10 @@ class CounterView(StatefulLayoutView):
179
179
  subscribed_actions = {"SCOPED_UPDATE"}
180
180
  state_scope = "user"
181
181
 
182
+ def __init__(self, *args, **kwargs):
183
+ super().__init__(*args, **kwargs)
184
+ self.build_ui() # First render: send() needs components to ship
185
+
182
186
  def build_ui(self):
183
187
  self.clear_items()
184
188
  count = self.scoped_state.get("count", 0)
@@ -422,7 +426,7 @@ class ConfigHub(MenuLayoutView):
422
426
  ]
423
427
  super().__init__(*args, categories=categories, **kwargs)
424
428
 
425
- def _build_header(self):
429
+ def build_header(self):
426
430
  return [card("## Server Config", key_value(self._summary()))]
427
431
  ```
428
432
 
@@ -650,8 +654,10 @@ for row in rows:
650
654
 
651
655
  ### Components
652
656
  - Stateful buttons, selects, and modals with state integration
657
+ - Modal inputs beyond text: `Checkbox`, `CheckboxGroup`, `RadioGroup`, `FileUpload`, sharing one validator + custom_id contract with `TextInput`
653
658
  - Select callbacks can opt into a `values` second parameter
654
- - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
659
+ - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `choice_row()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
660
+ - V2 stateful composites: `PaginatedRegion` (per-section pager), `Collapsible` (inline disclosure)
655
661
  - Grid helpers: `emoji_grid()` and `button_grid()`
656
662
  - Typed modal fields (`text`, `integer`, `float`, `date`) with per-field validation
657
663
  - Declarative `FormSchema` and `WizardSchema` base classes
@@ -136,6 +136,10 @@ class CounterView(StatefulLayoutView):
136
136
  subscribed_actions = {"SCOPED_UPDATE"}
137
137
  state_scope = "user"
138
138
 
139
+ def __init__(self, *args, **kwargs):
140
+ super().__init__(*args, **kwargs)
141
+ self.build_ui() # First render: send() needs components to ship
142
+
139
143
  def build_ui(self):
140
144
  self.clear_items()
141
145
  count = self.scoped_state.get("count", 0)
@@ -379,7 +383,7 @@ class ConfigHub(MenuLayoutView):
379
383
  ]
380
384
  super().__init__(*args, categories=categories, **kwargs)
381
385
 
382
- def _build_header(self):
386
+ def build_header(self):
383
387
  return [card("## Server Config", key_value(self._summary()))]
384
388
  ```
385
389
 
@@ -607,8 +611,10 @@ for row in rows:
607
611
 
608
612
  ### Components
609
613
  - Stateful buttons, selects, and modals with state integration
614
+ - Modal inputs beyond text: `Checkbox`, `CheckboxGroup`, `RadioGroup`, `FileUpload`, sharing one validator + custom_id contract with `TextInput`
610
615
  - Select callbacks can opt into a `values` second parameter
611
- - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
616
+ - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `choice_row()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
617
+ - V2 stateful composites: `PaginatedRegion` (per-section pager), `Collapsible` (inline disclosure)
612
618
  - Grid helpers: `emoji_grid()` and `button_grid()`
613
619
  - Typed modal fields (`text`, `integer`, `float`, `date`) with per-field validation
614
620
  - Declarative `FormSchema` and `WizardSchema` base classes
@@ -22,8 +22,11 @@ from .components.buttons import (
22
22
  )
23
23
  from .components.inputs import Checkbox, CheckboxGroup, FileUpload, Modal, RadioGroup, TextInput
24
24
  from .components.patterns import (
25
+ Choice,
26
+ Collapsible,
25
27
  ConfirmationButtons,
26
28
  EmojiGrid,
29
+ PaginatedRegion,
27
30
  PaginationControls,
28
31
  ProgressBar,
29
32
  ToggleGroup,
@@ -32,6 +35,7 @@ from .components.patterns import (
32
35
  button_grid,
33
36
  button_row,
34
37
  card,
38
+ choice_row,
35
39
  confirm_section,
36
40
  cycle_button,
37
41
  divider,
@@ -55,7 +59,7 @@ from .components.selects import (
55
59
  RoleSelect,
56
60
  UserSelect,
57
61
  )
58
- from .components.types import EmojiInput, MediaInput
62
+ from .components.types import MAX_SELECT_OPTIONS, EmojiInput, MediaInput
59
63
  from .components.v1_composition import CompositeComponent, get_component, register_component
60
64
  from .components.wrappers import with_confirmation, with_cooldown, with_loading_state
61
65
  from .devtools import DevToolsCog, InspectorView
@@ -100,11 +104,12 @@ from .utils.decorators import cascade_component, cascade_reducer
100
104
  from .utils.errors import safe_execute, with_error_boundary, with_retry
101
105
  from .utils.fetch import fetch_as_file
102
106
  from .utils.logging import setup_logging
103
- from .utils.strings import slugify
107
+ from .utils.strings import is_emoji, slugify
104
108
  from .utils.tasks import get_task_manager
105
109
  from .validation import (
106
110
  ValidationResult,
107
111
  choices,
112
+ emoji,
108
113
  max_length,
109
114
  max_value,
110
115
  min_length,
@@ -147,7 +152,7 @@ if "SQLiteBackend" in _persistence_all:
147
152
  # // ========================================( Script )======================================== // #
148
153
 
149
154
 
150
- __version__ = "3.3.4"
155
+ __version__ = "3.4.1"
151
156
 
152
157
  # Export public API
153
158
  __all__ = [
@@ -226,6 +231,7 @@ __all__ = [
226
231
  # Type aliases
227
232
  "EmojiInput",
228
233
  "MediaInput",
234
+ "MAX_SELECT_OPTIONS",
229
235
  # V2 Cards & Sections
230
236
  "card",
231
237
  "action_section",
@@ -237,6 +243,9 @@ __all__ = [
237
243
  "button_row",
238
244
  "cycle_button",
239
245
  "toggle_button",
246
+ # V2 Choices
247
+ "Choice",
248
+ "choice_row",
240
249
  # V2 Content
241
250
  "key_value",
242
251
  "alert",
@@ -247,6 +256,10 @@ __all__ = [
247
256
  "gap",
248
257
  # V2 Navigation
249
258
  "tab_nav",
259
+ # V2 Pagination
260
+ "PaginatedRegion",
261
+ # V2 Collapsible
262
+ "Collapsible",
250
263
  # V2 Media
251
264
  "gallery",
252
265
  "file_attachment",
@@ -279,6 +292,7 @@ __all__ = [
279
292
  "ValidationResult",
280
293
  "validate_field",
281
294
  "validate_fields",
295
+ "emoji",
282
296
  "min_length",
283
297
  "max_length",
284
298
  "regex",
@@ -307,6 +321,7 @@ __all__ = [
307
321
  "cascade_reducer",
308
322
  "cascade_component",
309
323
  "slugify",
324
+ "is_emoji",
310
325
  "fetch_as_file",
311
326
  # DevTools
312
327
  "InspectorView",
@@ -1,14 +1,18 @@
1
1
  # // ========================================( Modules )======================================== // #
2
2
 
3
3
 
4
+ import logging
4
5
  from typing import Any, Callable, Dict, List, Optional, Union
5
6
 
6
7
  import discord
7
8
  from discord import CheckboxGroupOption, Interaction, RadioGroupOption, TextStyle
8
9
 
10
+ from ..utils.strings import slugify
9
11
  from ..validation import validate_fields
10
12
  from .base import StatefulComponent
11
13
 
14
+ logger = logging.getLogger(__name__)
15
+
12
16
  # // ========================================( Classes )======================================== // #
13
17
 
14
18
 
@@ -72,11 +76,12 @@ class TextInput(StatefulComponent):
72
76
  def _slug(cls, label: str) -> str:
73
77
  """Derive a stable ``custom_id`` slug from a field label.
74
78
 
75
- Single source of truth for the slug rule. Used both during
76
- :class:`TextInput` construction and by form patterns when
77
- round-tripping ``"text"`` field values through a modal.
79
+ Single source of truth for the modal-input slug rule. Prefixes
80
+ ``input_`` and delegates to :func:`slugify`, so modal inputs share
81
+ the one safe lowercase-alphanumeric derivation the rest of the
82
+ library uses.
78
83
  """
79
- return f"input_{label.lower().replace(' ', '_')}"
84
+ return f"input_{slugify(label)}"
80
85
 
81
86
  def create_discord_component(self):
82
87
  """Build a ``ui.Label`` wrapping the inner ``ui.TextInput``."""
@@ -181,6 +186,13 @@ class CheckboxGroup(StatefulComponent):
181
186
  self.label = label
182
187
  self.description = description
183
188
  self.options = self._process_options(options, CheckboxGroupOption)
189
+ # Discord rejects an out-of-range option count with HTTP 400 when
190
+ # the modal opens; raising here surfaces the mistake at its source.
191
+ if not 1 <= len(self.options) <= 10:
192
+ raise ValueError(
193
+ f"CheckboxGroup {label!r} has {len(self.options)} options; Discord "
194
+ f"accepts 1-10. Trim the list or split the choices across inputs."
195
+ )
184
196
  self.required = required
185
197
  self.min_values = min_values
186
198
  self.max_values = max_values
@@ -254,6 +266,14 @@ class RadioGroup(StatefulComponent):
254
266
  self.label = label
255
267
  self.description = description
256
268
  self.options = CheckboxGroup._process_options(options, RadioGroupOption)
269
+ # Discord rejects an out-of-range option count with HTTP 400 when
270
+ # the modal opens; raising here surfaces the mistake at its source.
271
+ if not 2 <= len(self.options) <= 10:
272
+ raise ValueError(
273
+ f"RadioGroup {label!r} has {len(self.options)} options; Discord "
274
+ f"requires 2-10. Use a Checkbox for a single yes/no choice, or "
275
+ f"trim the list."
276
+ )
257
277
  self.required = required
258
278
  self.validators: List[Callable] = list(validators) if validators else []
259
279
  self.custom_id = TextInput._slug(label)
@@ -418,6 +438,7 @@ class Modal(discord.ui.Modal, StatefulComponent):
418
438
  if isinstance(input_item, _WRAPPED_INPUT_TYPES):
419
439
  label_wrapper = input_item.create_discord_component()
420
440
  inner = label_wrapper.component
441
+ self._reject_duplicate_input(input_item.custom_id)
421
442
  self.add_item(label_wrapper)
422
443
  self.inputs[input_item.custom_id] = input_item
423
444
  self._wrapped_pairs.append((input_item, inner))
@@ -428,10 +449,28 @@ class Modal(discord.ui.Modal, StatefulComponent):
428
449
  # modal-compatible component the user constructed directly.
429
450
  self.add_item(input_item)
430
451
  inner = _unwrap_label(input_item)
452
+ self._reject_duplicate_input(inner.custom_id)
431
453
  self.inputs[inner.custom_id] = input_item
432
454
 
433
455
  self.user_callback = callback
434
456
 
457
+ def _reject_duplicate_input(self, custom_id: str) -> None:
458
+ """Raise before a duplicate input custom_id silently overwrites.
459
+
460
+ Modal input ids derive from the label (``input_{label}``), so two
461
+ inputs with the same label collide on one key. Without this guard
462
+ the second silently overwrites the first in ``self.inputs`` and
463
+ its submitted value is lost at ``on_submit``.
464
+ """
465
+ if custom_id in self.inputs:
466
+ raise ValueError(
467
+ f"Duplicate modal input custom_id: {custom_id!r}. Modal input ids derive "
468
+ f"from the label ('input_{{label}}'), so two inputs with the same label "
469
+ f"collide on one key: one would silently overwrite the other and its "
470
+ f"submitted value would be lost.\n"
471
+ f" Fix: Give the colliding inputs distinct labels."
472
+ )
473
+
435
474
  async def on_submit(self, interaction: Interaction):
436
475
  """Handle modal submission with optional validation."""
437
476
  # Collect values from the underlying discord.py components,
@@ -469,8 +508,11 @@ class Modal(discord.ui.Modal, StatefulComponent):
469
508
  if errors:
470
509
  lines = []
471
510
  for field_id, field_errors in errors.items():
511
+ # Field label ("Emoji"), not the derived custom_id slug ("input_emoji").
512
+ field = self.inputs.get(field_id)
513
+ name = getattr(field, "label", field_id)
472
514
  for err in field_errors:
473
- lines.append(f"**{field_id}**: {err.message}")
515
+ lines.append(f"**{name}**: {err.message}")
474
516
  await interaction.response.send_message("\n".join(lines), ephemeral=True)
475
517
  return
476
518
 
@@ -492,7 +534,29 @@ class Modal(discord.ui.Modal, StatefulComponent):
492
534
  if self.user_callback:
493
535
  await self.user_callback(interaction, values)
494
536
  # Safety net: defer if the callback forgot to respond
495
- if not interaction.response.is_done():
496
- await interaction.response.defer()
537
+ await self._safe_post_submit_defer(interaction)
497
538
  else:
539
+ await self._safe_post_submit_defer(interaction)
540
+
541
+ async def _safe_post_submit_defer(self, interaction: Interaction) -> None:
542
+ """Acknowledge the modal submission if the callback left it unanswered.
543
+
544
+ Mirrors the post-callback defer in the view's ``_scheduled_task``:
545
+ the submission's state dispatch has already landed, so a dead or
546
+ already-acked interaction on the trailing ack must not turn a
547
+ successful submit into an unhandled error routed to ``on_error``.
548
+ """
549
+ if interaction.response.is_done():
550
+ return
551
+ try:
498
552
  await interaction.response.defer()
553
+ except discord.NotFound:
554
+ # 10062: the interaction token is already gone -- nothing to ack.
555
+ logger.debug("Modal post-submit defer hit a dead interaction (10062)")
556
+ except discord.HTTPException as e:
557
+ # 40060 (already acknowledged) is a benign cancellation race; any
558
+ # other status is a genuine ack failure the user saw as a toast.
559
+ if e.code == 40060:
560
+ logger.debug("Modal post-submit defer raced an existing ack (40060)")
561
+ else:
562
+ logger.warning(f"Modal post-submit defer failed: status={e.status} code={e.code}")
@@ -3,12 +3,16 @@
3
3
 
4
4
  from .v1 import ConfirmationButtons, PaginationControls, ProgressBar, ToggleGroup
5
5
  from .v2 import (
6
+ Choice,
7
+ Collapsible,
6
8
  EmojiGrid,
9
+ PaginatedRegion,
7
10
  action_section,
8
11
  alert,
9
12
  button_grid,
10
13
  button_row,
11
14
  card,
15
+ choice_row,
12
16
  confirm_section,
13
17
  cycle_button,
14
18
  divider,
@@ -46,6 +50,9 @@ __all__ = [
46
50
  "button_row",
47
51
  "cycle_button",
48
52
  "toggle_button",
53
+ # V2 choices
54
+ "Choice",
55
+ "choice_row",
49
56
  # V2 content
50
57
  "key_value",
51
58
  "alert",
@@ -56,6 +63,10 @@ __all__ = [
56
63
  "gap",
57
64
  # V2 navigation
58
65
  "tab_nav",
66
+ # V2 pagination
67
+ "PaginatedRegion",
68
+ # V2 collapsible
69
+ "Collapsible",
59
70
  # V2 media
60
71
  "gallery",
61
72
  "file_attachment",