diff --git a/packages/forms/docs/03-fields/03-select.md b/packages/forms/docs/03-fields/03-select.md
index 97f6b15e57..24c7040168 100644
--- a/packages/forms/docs/03-fields/03-select.md
+++ b/packages/forms/docs/03-fields/03-select.md
@@ -407,6 +407,46 @@ Select::make('status')
->in(fn (Select $component): array => array_keys($component->getEnabledOptions()))
```
+## Distinct selection in a Repeater
+
+You may enforce selection of any given option in only one instance of a Repeater by using the `distinct()` method.
+
+This example of creating teams for a tournament in a Repeater would only allow each shirt color to be chosen once:
+
+```php
+use Filament\Forms\Components\Repeaater;
+use Filament\Forms\Components\Select;
+
+Repeaater::make('teams')
+ ->maxItems(5)
+ ->schema([
+ Select::make('shirt_color')
+ ->options([
+ 'blue' => 'Blue',
+ 'green' => 'Green',
+ 'red' => 'Red',
+ 'yellow' => 'Yellow',
+ 'white' => 'White',
+ ])
+ ->distinct(),
+ //
+ ])
+```
+
+With no arguments, this will allow selection of the same color again, but will de-select the other occurrence of it.
+
+Alternatively, you may specify the `shouldDisableOptions` argument, which will disable any selected option in all other repeater instances, so once an option is selected in one instance, it cannot be selected in another. **Note** that this may impact performance for Selects with a lot of options.
+
+```php
+ Select::make('shirt_color')
+ ->options([
+ //
+ ])
+ ->distinct(shouldDisableOptions: true)
+```
+
+The `distinct()` method works for single and multiple selects, with and without `shouldDisableOptions`. Without the `shouldDisableOptions` argument, the `live()` method is automatically applied.
+
## Adding affix text aside the field
You may place text before and after the input using the `prefix()` and `suffix()` methods:
diff --git a/packages/forms/docs/03-fields/04-checkbox.md b/packages/forms/docs/03-fields/04-checkbox.md
index 7410672c0b..4554995498 100644
--- a/packages/forms/docs/03-fields/04-checkbox.md
+++ b/packages/forms/docs/03-fields/04-checkbox.md
@@ -54,6 +54,28 @@ Checkbox::make('is_admin')->inline(false)
+## Distinct selection in a Repeater
+
+You may enforce selection of a checkbox in one (and only one) instance of a Repeater by using the `distinct()` method.
+
+This example of adding multiple locations in a Repeater would require that one (and only one) instance is selected as primary:
+
+```php
+use Filament\Forms\Components\Checkbox;
+use Filament\Forms\Components\Repeaater;
+
+Repeaater::make('locations')
+ ->schema([
+ Checkbox::make('primary')
+ ->distinct(),
+ //
+ ])
+```
+
+Selecting the Primary checkbox in a second or subsequent repeat instance would de-select the previous selection.
+
+A validation is automatically added which will fail if no instance of the checkbox is selected, or if more than one is selected (which of course shouldn't happen with newly created data).
+
## Checkbox validation
As well as all rules listed on the [validation](../validation) page, there are additional rules that are specific to checkboxes.
diff --git a/packages/forms/docs/03-fields/05-toggle.md b/packages/forms/docs/03-fields/05-toggle.md
index 156ad0692b..01d1f6b41d 100644
--- a/packages/forms/docs/03-fields/05-toggle.md
+++ b/packages/forms/docs/03-fields/05-toggle.md
@@ -86,6 +86,28 @@ Toggle::make('is_admin')
+## Distinct selection in a Repeater
+
+You may enforce selection of a Toggle in one (and only one) instance of a Repeater by using the `distinct()` method.
+
+This example of adding multiple locations in a Repeater would require that one (and only one) instance is selected as primary:
+
+```php
+use Filament\Forms\Components\Checkbox;
+use Filament\Forms\Components\Repeaater;
+
+Repeaater::make('locations')
+ ->schema([
+ Toggle::make('primary')
+ ->distinct(),
+ //
+ ])
+```
+
+Selecting the Primary toggle in a second or subsequent repeat instance would de-select the previous selection.
+
+A validation is automatically added which will fail if no instance of the toggle is selected, or if more than one is selected (which of course shouldn't happen with newly created data).
+
## Toggle validation
As well as all rules listed on the [validation](../validation) page, there are additional rules that are specific to toggles.
diff --git a/packages/forms/docs/03-fields/06-checkbox-list.md b/packages/forms/docs/03-fields/06-checkbox-list.md
index c89d574650..60ef1b9ffd 100644
--- a/packages/forms/docs/03-fields/06-checkbox-list.md
+++ b/packages/forms/docs/03-fields/06-checkbox-list.md
@@ -132,6 +132,33 @@ CheckboxList::make('technologies')
->in(fn (CheckboxList $component): array => array_keys($component->getEnabledOptions()))
```
+## Distinct selection in a Repeater
+
+You may enforce selection of any given checkbox in only one instance of a Repeater by using the `distinct()` method.
+
+This example of assigning pets to rooms in your house would only allow each pet to be assigned to one room:
+
+```php
+use Filament\Forms\Components\CheckboxList;
+use Filament\Forms\Components\Repeaater;
+
+Repeaater::make('rooms')
+ ->schema([
+ CheckboxList::make('pets')
+ ->options([
+ 'della' => 'Della',
+ 'leslie' => 'Leslie',
+ 'rosie' => 'Rosie',
+ 'gracie' => 'Gracie',
+ 'mocheese' => 'MoCheese',
+ ])
+ ->distinct(),
+ //
+ ])
+```
+
+If any checkbox is selected again in a subsequent repeat instance, the previous selection will be de-selected.
+
## Searching options
You may enable a search input to allow easier access to many options, using the `searchable()` method:
diff --git a/packages/forms/docs/03-fields/07-radio.md b/packages/forms/docs/03-fields/07-radio.md
index 4fe8415240..e66fa73f76 100644
--- a/packages/forms/docs/03-fields/07-radio.md
+++ b/packages/forms/docs/03-fields/07-radio.md
@@ -101,3 +101,31 @@ Radio::make('status')
->disableOptionWhen(fn (string $value): bool => $value === 'published')
->in(fn (Radio $component): array => array_keys($component->getEnabledOptions()))
```
+
+## Distinct selection in a Repeater
+
+You may enforce selection of any given radio button in only one instance of a Repeater by using the `distinct()` method.
+
+This example of creating teams for a tournament in a Repeater would only allow each shirt color to be chosen once
+
+```php
+use Filament\Forms\Components\Radio;
+use Filament\Forms\Components\Repeaater;
+
+Repeaater::make('teams')
+ ->maxItems(5)
+ ->schema([
+ Radio::make('shirt_color')
+ ->options([
+ 'blue' => 'Blue',
+ 'green' => 'Green',
+ 'red' => 'Red',
+ 'yellow' => 'Yellow',
+ 'white' => 'White',
+ ])
+ ->distinct(),
+ //
+ ])
+```
+
+If any radio button is selected again in a subsequent repeat instance, the previous selection will be de-selected.
diff --git a/packages/forms/docs/03-fields/12-repeater.md b/packages/forms/docs/03-fields/12-repeater.md
index c7ad8f71aa..6d2ddd6791 100644
--- a/packages/forms/docs/03-fields/12-repeater.md
+++ b/packages/forms/docs/03-fields/12-repeater.md
@@ -460,6 +460,11 @@ You are trying to retrieve the value of `client_id` from inside the repeater ite
You can use `../` to go up a level in the data structure, so `$get('../client_id')` is `$get('repeater.client_id')` and `$get('../../client_id')` is `$get('client_id')`.
+## Distinct selection in a Repeater
+
+Fields which provide selection of predefined options like Select, Radio, etc. provide a `distinct()` method, which enforces only allowing each option to be selected in one instance of a Repeater.
+
+This functionality is documented in [Select](03-select.md#distinct-selection-in-a-repeater), [Checkbox](04-checkbox.md#distinct-selection-in-a-repeater), [CheckboxList](06-checkbox-list.md#distinct-selection-in-a-repeater), [Toggle](05-toggle.md#distinct-selection-in-a-repeater) and [Radio](07-radio.md#distinct-selection-in-a-repeater).
## Repeater validation
diff --git a/packages/forms/src/Components/Checkbox.php b/packages/forms/src/Components/Checkbox.php
index 11d06029d8..39a61b77f6 100644
--- a/packages/forms/src/Components/Checkbox.php
+++ b/packages/forms/src/Components/Checkbox.php
@@ -5,6 +5,7 @@ namespace Filament\Forms\Components;
class Checkbox extends Field
{
use Concerns\CanBeAccepted;
+ use Concerns\CanBeDistinct;
use Concerns\CanBeInline;
use Concerns\HasExtraInputAttributes;
diff --git a/packages/forms/src/Components/CheckboxList.php b/packages/forms/src/Components/CheckboxList.php
index 2b92451a4f..8673025632 100644
--- a/packages/forms/src/Components/CheckboxList.php
+++ b/packages/forms/src/Components/CheckboxList.php
@@ -17,6 +17,7 @@ use Illuminate\Support\Str;
class CheckboxList extends Field implements Contracts\HasNestedRecursiveValidationRules
{
use Concerns\CanBeSearchable;
+ use Concerns\CanBeDistinct;
use Concerns\CanDisableOptions;
use Concerns\HasExtraInputAttributes;
use Concerns\HasGridDirection;
diff --git a/packages/forms/src/Components/Component.php b/packages/forms/src/Components/Component.php
index 561e882bfe..c5157bdac6 100644
--- a/packages/forms/src/Components/Component.php
+++ b/packages/forms/src/Components/Component.php
@@ -13,6 +13,7 @@ class Component extends ViewComponent
use Concerns\BelongsToContainer;
use Concerns\BelongsToModel;
use Concerns\CanBeConcealed;
+ use Concerns\CanBeRepeated;
use Concerns\CanBeDisabled;
use Concerns\CanBeHidden;
use Concerns\CanSpanColumns;
diff --git a/packages/forms/src/Components/Concerns/CanBeDistinct.php b/packages/forms/src/Components/Concerns/CanBeDistinct.php
new file mode 100644
index 0000000000..0b91590bbf
--- /dev/null
+++ b/packages/forms/src/Components/Concerns/CanBeDistinct.php
@@ -0,0 +1,142 @@
+isDistinct = $condition;
+
+ // In shouldDisableOptions mode, any option(s) selected in one instance of the field will be
+ // disabled in all other instances, using disableOptionWhen(). This really only makes sense for
+ // Select, and not for Radio, Toggle or Checkbox. So in this code, Select can work in either mode,
+ // the rest will ignore shouldDisableOptions.
+ //
+ // When not in shouldDisableOptions mode, any option(s) selected in one instance of the field
+ // will be de-selected in all other instances. This requires setting the field to be live(), and
+ // handling state with afterStateUpdated() and $set.
+
+ if ($this instanceof Select && $shouldDisableOptions) {
+ $this->disableOptionWhen(static function (Component $component, string $value, mixed $state) {
+ if (! $component->isRepeated()) {
+ return false;
+ }
+
+ // collect the state of all other instances of this field, and check if this value already
+ // exists. Using flatten() and wrap() means this will work for single or multiple select.
+ return collect($component->getRepeaterComponent()?->getState())
+ ->pluck($component->getName())
+ ->flatten()
+ ->diff(Arr::wrap($state))
+ ->filter()
+ ->contains($value);
+ });
+ } else {
+ $this->live();
+
+ $this->afterStateUpdated(static function (Component $component, mixed $state, Set $set) {
+ $repeaterPath = $component->getRepeaterComponent()?->getStatePath();
+
+ $repeatKey = (string) Str::of($component->getStatePath())
+ ->beforeLast(".{$component->getName()}")
+ ->afterLast('.');
+
+ $siblings = collect($component->getRepeaterComponent()?->getState())
+ ->reject(fn (array $item, string $key): bool => $key === $repeatKey);
+
+ if (is_array($state)) {
+ // get the repeat keys of any other instances of this field with options that intersect with $state and
+ // set them to the diff (so remove the intersections)
+ $siblings->filter(fn (array $item): bool => ! empty(array_intersect(data_get($item, $component->getName(), []), $state)))
+ ->mapWithKeys(fn (array $item, string $key): array => [$key => array_values(array_diff(data_get($item, $component->getName(), []), $state))])
+ ->each(function (array $newState, string $key) use ($set, $repeaterPath, $component) {
+ $set(path: "{$repeaterPath}.{$key}.{$component->getName()}", state: $newState, isAbsolute: true);
+ });
+ } else {
+ // get the repeat keys of any other instances of this field with the same $state and set them to null.
+ // @TODO figure out if we need to make the 'null' value configurable, like should it be 'false' for
+ // Toggles, or use a specified default(), etc?
+ $siblings->filter(fn (array $item): bool => data_get($item, $component->getName()) === $state)
+ ->map(fn (array $item, string $key): string => $key)
+ ->each(function (string $key) use ($set, $repeaterPath, $component) {
+ $set(path: "{$repeaterPath}.{$key}.{$component->getName()}", state: null, isAbsolute: true);
+ });
+ }
+ });
+ }
+
+ $this->rule(static function (Component $component, mixed $state) {
+ return function (string $attribute, $value, Closure $fail) use ($component, $state) {
+ if (! $component->isRepeated()) {
+ return;
+ }
+
+ $repeatKey = (string) Str::of($component->getStatePath())
+ ->beforeLast(".{$component->getName()}")
+ ->afterLast('.');
+
+ $siblings = collect($component->getRepeaterComponent()?->getState())
+ ->reject(fn (array $item, string $key): bool => $key === $repeatKey);
+
+ if (count($siblings) === 0) {
+ return;
+ }
+
+ if (is_bool($state)) {
+ // if it's boolean, it's a Toggle or Checkbox, so "one, and only one, must be selected"
+ $selected = collect($siblings)
+ ->pluck($component->getName())
+ ->contains(true);
+
+ if (empty($selected)) {
+ $fail("One of {$component->getLabel()} must be selected.");
+
+ return;
+ }
+
+ if ($value) {
+ $isDuplicate = $siblings->pluck($component->getName())
+ ->contains(true);
+
+ if ($isDuplicate) {
+ $fail("Only one of {$component->getLabel()} may be selected.");
+ }
+ }
+ } elseif (is_array($state)) {
+ // an array is Select multiple() or CheckboxList, so test for any intersection with other instances
+ $intersects = $siblings->filter(fn (array $item): bool => ! empty(array_intersect(data_get($item, $component->getName(), []), $state)))
+ ->isNotEmpty();
+
+ if ($intersects) {
+ $fail("{$component->getLabel()} field must be distinct.");
+ }
+ } else {
+ // it's a single select of some sort, so test for duplicates
+ $isDuplicate = $siblings->pluck($component->getName())
+ ->contains($state);
+
+ if ($isDuplicate) {
+ $fail("{$component->getLabel()} field must be distinct.");
+ }
+ }
+ };
+ });
+
+ return $this;
+ }
+
+ public function isDistinct(): bool
+ {
+ return (bool) $this->evaluate($this->isDistinct);
+ }
+}
diff --git a/packages/forms/src/Components/Concerns/CanBeRepeated.php b/packages/forms/src/Components/Concerns/CanBeRepeated.php
new file mode 100644
index 0000000000..940b443b70
--- /dev/null
+++ b/packages/forms/src/Components/Concerns/CanBeRepeated.php
@@ -0,0 +1,35 @@
+cachedRepeaterComponent)) {
+ return $this->cachedRepeaterComponent ?: null;
+ }
+
+ $parentComponent = $this->getContainer()->getParentComponent();
+
+ if (! $parentComponent) {
+ $this->cachedRepeaterComponent = false;
+ } elseif ($parentComponent instanceof Repeater) {
+ $this->cachedRepeaterComponent = $parentComponent;
+ } else {
+ $this->cachedRepeaterComponent = $parentComponent->getRepeaterComponent();
+ }
+
+ return $this->cachedRepeaterComponent ?: null;
+ }
+
+ public function isRepeated(): bool
+ {
+ return (bool) $this->getRepeaterComponent();
+ }
+}
diff --git a/packages/forms/src/Components/Radio.php b/packages/forms/src/Components/Radio.php
index 68b42111aa..9850384c8a 100644
--- a/packages/forms/src/Components/Radio.php
+++ b/packages/forms/src/Components/Radio.php
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Support\Htmlable;
class Radio extends Field
{
+ use Concerns\CanBeDistinct;
use Concerns\HasExtraInputAttributes;
use Concerns\HasGridDirection;
use Concerns\HasOptions;
@@ -26,11 +27,6 @@ class Radio extends Field
protected bool | Closure | null $isOptionDisabled = null;
- protected function setUp(): void
- {
- parent::setUp();
- }
-
public function boolean(string $trueLabel = 'Yes', string $falseLabel = 'No'): static
{
$this->options([
diff --git a/packages/forms/src/Components/Select.php b/packages/forms/src/Components/Select.php
index eba6e0db03..fd0e04c594 100644
--- a/packages/forms/src/Components/Select.php
+++ b/packages/forms/src/Components/Select.php
@@ -29,6 +29,7 @@ class Select extends Field implements Contracts\HasAffixActions, Contracts\HasNe
use Concerns\CanAllowHtml;
use Concerns\CanBePreloaded;
use Concerns\CanBeSearchable;
+ use Concerns\CanBeDistinct;
use Concerns\CanDisableOptions;
use Concerns\CanSelectPlaceholder;
use Concerns\CanLimitItemsLength;
@@ -1182,6 +1183,10 @@ class Select extends Field implements Contracts\HasAffixActions, Contracts\HasNe
public function hasDynamicOptions(): bool
{
+ if ($this->isDistinct()) {
+ return true;
+ }
+
if ($this->hasRelationship()) {
return $this->isPreloaded();
}
diff --git a/packages/forms/src/Components/Toggle.php b/packages/forms/src/Components/Toggle.php
index ec517a8057..a77148b0bf 100644
--- a/packages/forms/src/Components/Toggle.php
+++ b/packages/forms/src/Components/Toggle.php
@@ -7,6 +7,7 @@ use Filament\Support\Concerns\HasExtraAlpineAttributes;
class Toggle extends Field
{
use Concerns\CanBeAccepted;
+ use Concerns\CanBeDistinct;
use Concerns\CanBeInline;
use Concerns\HasToggleColors;
use Concerns\HasToggleIcons;
diff --git a/packages/tables/src/Table/Concerns/HasQuery.php b/packages/tables/src/Table/Concerns/HasQuery.php
index 3da7b669e0..abba113e04 100644
--- a/packages/tables/src/Table/Concerns/HasQuery.php
+++ b/packages/tables/src/Table/Concerns/HasQuery.php
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
-
use function Livewire\invade;
trait HasQuery