Basic select col implementation

This commit is contained in:
Dan Harrin
2025-08-07 08:57:44 +01:00
parent 7cacf9a78f
commit 8a02cfcfcf
13 changed files with 203 additions and 171 deletions
-12
View File
@@ -143,18 +143,6 @@ schemaComponents.forEach((component) => {
})
})
const supportComponents = ['select']
supportComponents.forEach((component) => {
compile({
...defaultOptions,
entryPoints: [
`./packages/support/resources/js/components/${component}.js`,
],
outfile: `./packages/support/dist/components/${component}.js`,
})
})
const tableColumns = ['checkbox', 'select', 'text-input', 'toggle']
tableColumns.forEach((column) => {
File diff suppressed because one or more lines are too long
@@ -34,9 +34,10 @@ export default function selectFormComponent({
statePath,
}) {
return {
state,
select: null,
state,
init() {
this.select = new Select({
element: this.$refs.select,
@@ -190,7 +190,7 @@
{{
$attributes
->merge($getExtraAlpineAttributes(), escape: false)
->class(['fi-select'])
->class(['fi-select-input'])
}}
>
<div x-ref="select"></div>
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
.fi-select-input {
select.fi-select-input {
@apply block w-full appearance-none border-none bg-transparent py-1.5 ps-3 pe-8 text-sm leading-6 text-gray-950 transition duration-75 focus:ring-0 focus:outline-none disabled:text-gray-500 disabled:[-webkit-text-fill-color:var(--color-gray-500)] dark:text-white dark:disabled:text-gray-400 dark:disabled:[-webkit-text-fill-color:var(--color-gray-400)] [&_optgroup]:bg-white [&_optgroup]:dark:bg-gray-900 [&_option]:bg-white [&_option]:dark:bg-gray-900;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
@@ -15,8 +15,8 @@
}
}
.fi-select {
& .fi-select-ctn {
.fi-select-input {
& .fi-select-input-ctn {
@apply relative;
}
@@ -24,7 +24,7 @@
@apply min-h-9;
}
& .fi-select-btn {
& .fi-select-input-btn {
@apply flex min-h-9 w-full rounded-lg py-1.5 ps-3 pe-8 text-start text-sm leading-6 text-gray-950 focus:ring-0 focus:outline-none dark:text-white;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
@@ -37,20 +37,20 @@
}
}
& .fi-select-value-ctn {
& .fi-select-input-value-ctn {
@apply flex w-full items-center text-wrap;
word-break: break-word;
}
& .fi-select-value-badges-ctn {
& .fi-select-input-value-badges-ctn {
@apply flex flex-wrap gap-1.5;
}
& .fi-select-value-label {
& .fi-select-input-value-label {
@apply flex-1;
}
& .fi-select-value-remove-btn {
& .fi-select-input-value-remove-btn {
@apply text-gray-500 hover:text-gray-600 focus-visible:text-gray-600 focus-visible:outline-none dark:hover:text-gray-300 dark:focus-visible:text-gray-300;
}
@@ -58,19 +58,19 @@
@apply max-h-60 max-w-full!;
}
& .fi-select-options-ctn {
& .fi-select-input-options-ctn {
@apply divide-y divide-gray-100 dark:divide-white/5;
}
& .fi-select-option-group {
& .fi-select-input-option-group {
@apply divide-y divide-gray-100 dark:divide-white/5;
}
& .fi-select-search-ctn {
& .fi-select-input-search-ctn {
@apply sticky top-0 z-10 bg-white dark:bg-gray-900;
}
& .fi-select-option {
& .fi-select-input-option {
@apply min-w-px text-wrap;
word-break: break-word;
}
@@ -79,16 +79,16 @@
@apply cursor-not-allowed opacity-70;
}
& .fi-select-message {
& .fi-select-input-message {
@apply px-3 py-2 text-sm text-gray-500 dark:text-gray-400;
}
& .fi-select-ctn.fi-select-ctn-option-labels-not-wrapped {
& .fi-select-value-ctn > span {
& .fi-select-input-ctn.fi-select-input-ctn-option-labels-not-wrapped {
& .fi-select-input-value-ctn > span {
@apply truncate text-nowrap break-normal;
}
& .fi-select-option > span {
& .fi-select-input-option > span {
@apply truncate;
}
}
@@ -1,95 +0,0 @@
import { Select } from '../utilities/select.js'
export default function selectFormComponent({
canOptionLabelsWrap,
canSelectPlaceholder,
isHtmlAllowed,
getOptionLabelUsing,
getOptionLabelsUsing,
getOptionsUsing,
getSearchResultsUsing,
initialOptionLabel,
initialOptionLabels,
initialState,
isAutofocused,
isDisabled,
isMultiple,
isSearchable,
hasDynamicOptions,
hasDynamicSearchResults,
livewireId,
loadingMessage,
maxItems,
maxItemsMessage,
noSearchResultsMessage,
options,
optionsLimit,
placeholder,
position,
searchDebounce,
searchingMessage,
searchPrompt,
searchableOptionFields,
state,
statePath,
}) {
return {
state,
select: null,
init() {
this.select = new Select({
element: this.$refs.select,
options,
placeholder,
state: this.state,
canOptionLabelsWrap,
canSelectPlaceholder,
initialOptionLabel,
initialOptionLabels,
initialState,
isHtmlAllowed,
isAutofocused,
isDisabled,
isMultiple,
isSearchable,
getOptionLabelUsing,
getOptionLabelsUsing,
getOptionsUsing,
getSearchResultsUsing,
hasDynamicOptions,
hasDynamicSearchResults,
searchPrompt,
searchDebounce,
loadingMessage,
searchingMessage,
noSearchResultsMessage,
maxItems,
maxItemsMessage,
optionsLimit,
position,
searchableOptionFields,
livewireId,
statePath,
onStateChange: (newState) => {
this.state = newState
},
})
this.$watch('state', (newState) => {
if (this.select && this.select.state !== newState) {
this.select.state = newState
this.select.updateSelectedDisplay()
this.select.renderOptions()
}
})
},
destroy() {
if (this.select) {
this.select.destroy()
this.select = null
}
},
}
}
@@ -130,11 +130,11 @@ export class Select {
// Create the main container
this.container = document.createElement('div')
this.container.className = 'fi-select-ctn'
this.container.className = 'fi-select-input-ctn'
if (!this.canOptionLabelsWrap) {
this.container.classList.add(
'fi-select-ctn-option-labels-not-wrapped',
'fi-select-input-ctn-option-labels-not-wrapped',
)
}
@@ -142,13 +142,13 @@ export class Select {
// Create the button that toggles the dropdown
this.selectButton = document.createElement('button')
this.selectButton.className = 'fi-select-btn'
this.selectButton.className = 'fi-select-input-btn'
this.selectButton.type = 'button'
this.selectButton.setAttribute('aria-expanded', 'false')
// Create the selected value display
this.selectedDisplay = document.createElement('div')
this.selectedDisplay.className = 'fi-select-value-ctn'
this.selectedDisplay.className = 'fi-select-input-value-ctn'
// Update the selected display based on current state
this.updateSelectedDisplay()
@@ -163,7 +163,7 @@ export class Select {
this.dropdown.style.display = 'none'
// Generate a unique ID for the dropdown
this.dropdownId = `fi-select-dropdown-${Math.random().toString(36).substring(2, 11)}`
this.dropdownId = `fi-select-input-dropdown-${Math.random().toString(36).substring(2, 11)}`
this.dropdown.id = this.dropdownId
// Set aria-multiselectable for multi-select
@@ -174,7 +174,7 @@ export class Select {
// Add search input if searchable
if (this.isSearchable) {
this.searchContainer = document.createElement('div')
this.searchContainer.className = 'fi-select-search-ctn'
this.searchContainer.className = 'fi-select-input-search-ctn'
this.searchInput = document.createElement('input')
this.searchInput.className = 'fi-input'
@@ -308,7 +308,7 @@ export class Select {
// Set the appropriate class based on whether we have grouped options
if (hasGroupedOptions) {
this.optionsList.className = 'fi-select-options-ctn'
this.optionsList.className = 'fi-select-input-options-ctn'
} else if (optionsCount > 0) {
// Only set fi-dropdown-list class if there are options to render
this.optionsList.className = 'fi-dropdown-list'
@@ -417,7 +417,7 @@ export class Select {
}
const optionGroup = document.createElement('li')
optionGroup.className = 'fi-select-option-group'
optionGroup.className = 'fi-select-input-option-group'
const optionGroupLabel = document.createElement('div')
optionGroupLabel.className = 'fi-dropdown-header'
@@ -454,14 +454,14 @@ export class Select {
}
const option = document.createElement('li')
option.className = 'fi-dropdown-list-item fi-select-option'
option.className = 'fi-dropdown-list-item fi-select-input-option'
if (isDisabled) {
option.classList.add('fi-disabled')
}
// Generate a unique ID for the option
const optionId = `fi-select-option-${Math.random().toString(36).substring(2, 11)}`
const optionId = `fi-select-input-option-${Math.random().toString(36).substring(2, 11)}`
option.id = optionId
option.setAttribute('role', 'option')
@@ -747,7 +747,7 @@ export class Select {
addBadgesForSelectedOptions(selectedLabels) {
// Create a container for the badges
const badgesContainer = document.createElement('div')
badgesContainer.className = 'fi-select-value-badges-ctn'
badgesContainer.className = 'fi-select-input-value-badges-ctn'
// Add badges for each selected option
selectedLabels.forEach((label, index) => {
@@ -807,7 +807,7 @@ export class Select {
addSingleSelectionDisplay(selectedLabel) {
// Create a container for the label
const labelContainer = document.createElement('span')
labelContainer.className = 'fi-select-value-label'
labelContainer.className = 'fi-select-input-value-label'
if (this.isHtmlAllowed) {
labelContainer.innerHTML = selectedLabel
@@ -824,7 +824,7 @@ export class Select {
const removeButton = document.createElement('button')
removeButton.type = 'button'
removeButton.className = 'fi-select-value-remove-btn'
removeButton.className = 'fi-select-input-value-remove-btn'
removeButton.innerHTML =
'<svg class="fi-icon fi-size-sm" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>'
removeButton.setAttribute('aria-label', 'Clear selection')
@@ -961,7 +961,7 @@ export class Select {
// Update the displayed label
const labelContainer =
this.selectedDisplay.querySelector(
'.fi-select-value-label',
'.fi-select-input-value-label',
)
if (filled(labelContainer)) {
if (this.isHtmlAllowed) {
@@ -1486,7 +1486,7 @@ export class Select {
// Get all option elements that are in option groups
const groupOptions = Array.from(
this.optionsList.querySelectorAll(
'li.fi-select-option-group > ul > li[role="option"]',
'li.fi-select-input-option-group > ul > li[role="option"]',
),
)
@@ -1605,7 +1605,7 @@ export class Select {
// Add loading message
const loadingItem = document.createElement('div')
loadingItem.className = 'fi-select-message'
loadingItem.className = 'fi-select-input-message'
loadingItem.textContent = isSearching
? this.searchingMessage
: this.loadingMessage
@@ -1614,7 +1614,7 @@ export class Select {
hideLoadingState() {
// Remove loading message
const loadingItem = this.dropdown.querySelector('.fi-select-message')
const loadingItem = this.dropdown.querySelector('.fi-select-input-message')
if (loadingItem) {
loadingItem.remove()
}
@@ -1634,7 +1634,7 @@ export class Select {
// Add "No results" message
const noResultsItem = document.createElement('div')
noResultsItem.className = 'fi-select-message'
noResultsItem.className = 'fi-select-input-message'
noResultsItem.textContent = this.noSearchResultsMessage
this.dropdown.appendChild(noResultsItem)
}
@@ -1775,7 +1775,7 @@ export class Select {
// Check if we already have a badges container
const existingBadgesContainer = this.selectedDisplay.querySelector(
'.fi-select-value-badges-ctn',
'.fi-select-input-value-badges-ctn',
)
if (blank(existingBadgesContainer)) {
@@ -1912,7 +1912,7 @@ export class Select {
// If there are remove buttons in multiple mode, disable them
if (this.isMultiple) {
const removeButtons = this.container.querySelectorAll(
'.fi-select-badge-remove',
'.fi-select-input-badge-remove',
)
removeButtons.forEach((button) => {
button.setAttribute('disabled', 'disabled')
@@ -1923,7 +1923,7 @@ export class Select {
// If there's a remove button in single mode, disable it
if (!this.isMultiple && this.canSelectPlaceholder) {
const removeButton = this.container.querySelector(
'.fi-select-value-remove-btn',
'.fi-select-input-value-remove-btn',
)
if (removeButton) {
removeButton.setAttribute('disabled', 'disabled')
@@ -1945,7 +1945,7 @@ export class Select {
// If there are remove buttons in multiple mode, enable them
if (this.isMultiple) {
const removeButtons = this.container.querySelectorAll(
'.fi-select-badge-remove',
'.fi-select-input-badge-remove',
)
removeButtons.forEach((button) => {
button.removeAttribute('disabled')
@@ -1956,7 +1956,7 @@ export class Select {
// If there's a remove button in single mode, enable it
if (!this.isMultiple && this.canSelectPlaceholder) {
const removeButton = this.container.querySelector(
'.fi-select-value-remove-btn',
'.fi-select-input-value-remove-btn',
)
if (removeButton) {
removeButton.removeAttribute('disabled')
@@ -146,7 +146,6 @@ class SupportServiceProvider extends PackageServiceProvider
app('livewire')->componentHook(new PartialsComponentHook);
FilamentAsset::register([
AlpineComponent::make('select', __DIR__ . '/../dist/components/select.js'),
Js::make('support', __DIR__ . '/../dist/index.js'),
], 'filament/support');
File diff suppressed because one or more lines are too long
@@ -1,12 +1,64 @@
export default function selectTableColumn({ name, recordKey, state }) {
import { Select } from '../../../../../support/resources/js/utilities/select.js'
export default function selectTableColumn({
canSelectPlaceholder,
isNative,
name,
options,
placeholder,
recordKey,
state,
}) {
return {
error: undefined,
isLoading: false,
select: null,
state,
init() {
if (! isNative) {
this.select = new Select({
element: this.$refs.select,
options,
placeholder,
state: this.state,
// canOptionLabelsWrap,
canSelectPlaceholder,
// initialOptionLabel,
// initialOptionLabels,
// initialState,
// isHtmlAllowed,
// isAutofocused,
// isDisabled,
// isMultiple,
// isSearchable,
// getOptionLabelUsing,
// getOptionLabelsUsing,
// getOptionsUsing,
// getSearchResultsUsing,
// hasDynamicOptions,
// hasDynamicSearchResults,
// searchPrompt,
// searchDebounce,
// loadingMessage,
// searchingMessage,
// noSearchResultsMessage,
// maxItems,
// maxItemsMessage,
// optionsLimit,
// position,
// searchableOptionFields,
// livewireId,
// statePath,
onStateChange: (newState) => {
this.state = newState
},
})
}
Livewire.hook(
'commit',
({ component, commit, succeed, fail, respond }) => {
@@ -40,7 +92,13 @@ export default function selectTableColumn({ name, recordKey, state }) {
},
)
this.$watch('state', async () => {
this.$watch('state', async (newState) => {
if ((! isNative) && this.select && this.select.state !== newState) {
this.select.state = newState
this.select.updateSelectedDisplay()
this.select.renderOptions()
}
const serverState = this.getServerState()
if (
+98 -16
View File
@@ -3,18 +3,23 @@
namespace Filament\Tables\Columns;
use BackedEnum;
use Closure;
use Filament\Forms\Components\Concerns\CanDisableOptions;
use Filament\Forms\Components\Concerns\CanSelectPlaceholder;
use Filament\Forms\Components\Concerns\HasEnum;
use Filament\Forms\Components\Concerns\HasExtraInputAttributes;
use Filament\Forms\Components\Concerns\HasOptions;
use Filament\Forms\Components\Select;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Support\Facades\FilamentAsset;
use Filament\Tables\Columns\Contracts\Editable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Js;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Livewire\Attributes\Renderless;
class SelectColumn extends Column implements Editable, HasEmbeddedView
{
@@ -28,6 +33,10 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView
use HasExtraInputAttributes;
use HasOptions;
protected bool | Closure $isNative = true;
protected ?Closure $transformOptionsForJsUsing = null;
protected function setUp(): void
{
parent::setUp();
@@ -35,6 +44,15 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView
$this->disabledClick();
$this->placeholder(__('filament-forms::components.select.placeholder'));
$this->transformOptionsForJsUsing(static function (SelectColumn $column, array $options): array {
return collect($options)
->map(fn ($label, $value): array => is_array($label)
? ['label' => $value, 'options' => $column->transformOptionsForJs($label)]
: ['label' => $label, 'value' => strval($value), 'isDisabled' => $column->isOptionDisabled($value, $label)])
->values()
->all();
});
}
/**
@@ -50,6 +68,56 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView
];
}
public function native(bool | Closure $condition = true): static
{
$this->isNative = $condition;
return $this;
}
public function isNative(): bool
{
return (bool) $this->evaluate($this->isNative);
}
/**
* @return array<array{'label': string, 'value': string}>
*/
#[ExposedLivewireMethod]
#[Renderless]
public function getOptionsForJs(): array
{
return $this->transformOptionsForJs($this->getOptions());
}
public function transformOptionsForJsUsing(?Closure $callback): static
{
$this->transformOptionsForJsUsing = $callback;
return $this;
}
/**
* @param array<string | array<string>> $options
* @return array<array<string, mixed>>
*/
protected function transformOptionsForJs(array $options): array
{
if (empty($options)) {
return [];
}
$transformedOptions = $this->evaluate($this->transformOptionsForJsUsing, [
'options' => $options,
]);
if ($transformedOptions instanceof Arrayable) {
return $transformedOptions->toArray();
}
return $transformedOptions;
}
public function toEmbeddedHtml(): string
{
$isDisabled = $this->isDisabled();
@@ -60,7 +128,11 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView
'x-load' => true,
'x-load-src' => FilamentAsset::getAlpineComponentSrc('columns/select', 'filament/tables'),
'x-data' => 'selectTableColumn({
canSelectPlaceholder: ' . Js::from($this->canSelectPlaceholder()) . ',
isNative: ' . Js::from($this->isNative()) . ',
name: ' . Js::from($this->getName()) . ',
options: ' . Js::from($this->getOptionsForJs()) . ',
placeholder: ' . Js::from($this->getPlaceholder()) . ',
recordKey: ' . Js::from($this->getRecordKey()) . ',
state: ' . Js::from($state) . ',
})',
@@ -109,25 +181,35 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView
}
"
x-on:click.prevent.stop=""
<?php if (! $this->isNative()) { ?>
wire:ignore
x-on:keydown.esc="select.dropdown.isActive && $event.stopPropagation()"
<?php } ?>
class="fi-input-wrp"
>
<select
x-model="state"
<?= $inputAttributes->toHtml() ?>
>
<?php if ($this->canSelectPlaceholder()) { ?>
<option value=""><?= $this->getPlaceholder() ?></option>
<?php } ?>
<?php if ($this->isNative()) { ?>
<select
x-model="state"
<?= $inputAttributes->toHtml() ?>
>
<?php if ($this->canSelectPlaceholder()) { ?>
<option value=""><?= $this->getPlaceholder() ?></option>
<?php } ?>
<?php foreach ($this->getOptions() as $value => $label) { ?>
<option
<?= $this->isOptionDisabled($value, $label) ? 'disabled' : null ?>
value="<?= $value ?>"
>
<?= $label ?>
</option>
<?php } ?>
</select>
<?php foreach ($this->getOptions() as $value => $label) { ?>
<option
<?= $this->isOptionDisabled($value, $label) ? 'disabled' : null ?>
value="<?= $value ?>"
>
<?= $label ?>
</option>
<?php } ?>
</select>
<?php } else { ?>
<div class="fi-select-input">
<div x-ref="select"></div>
</div>
<?php } ?>
</div>
</div>