feat(InputWithSelect): add combinedBinding + structuredBinding support

This commit is contained in:
Dominic 2026-01-11 04:26:39 +01:00 committed by Dominic Schmid
parent ef0e33b9b9
commit 60ec7484b7
2 changed files with 85 additions and 26 deletions

View file

@ -14,6 +14,10 @@ class InputWithSelect extends Component
public ?string $htmlId = null;
public ?string $combinedBinding = null;
public ?string $structuredBinding = null;
public function __construct(
public ?string $id = null,
public ?string $name = null,
@ -72,7 +76,11 @@ class InputWithSelect extends Component
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
// Set default option if not provided and options exist
if ($this->modelBinding && $this->modelBinding !== 'null') {
$this->combinedBinding = $this->modelBinding;
$this->structuredBinding = $this->modelBinding . 'Structured';
}
if (is_null($this->defaultOption) && !empty($this->options)) {
$this->defaultOption = array_key_first($this->options);
}

View file

@ -8,10 +8,13 @@
defaultUnit: @js($defaultOption ?? ''),
min: @js($min),
max: @js($max),
validUnits: @js(array_keys($options)),
@if ($modelBinding !== 'null')
combinedValue: @entangle($modelBinding),
combinedValue: @entangle($combinedBinding),
structuredValue: @entangle($structuredBinding),
@else
combinedValue: @js($value ?? '0'),
structuredValue: @js(['value' => $value ?? '', 'unit' => $defaultOption ?? '']),
@endif
})"
x-ref="container">
@ -54,7 +57,7 @@
<select
@if ($selectId) id="{{ $selectId }}" @endif
x-model="selectValue"
@change="updateCombined()"
@change="updateStructured()"
@disabled($disabled)
name="{{ $name }}-select"
class="select rounded-l-none w-auto min-w-[70px] border-l-0"
@ -82,17 +85,41 @@ document.addEventListener('alpine:init', () => {
inputValue: '',
selectValue: config.defaultUnit,
combinedValue: config.combinedValue,
structuredValue: config.structuredValue,
pendingSync: null,
init() {
this.parseValue(this.combinedValue);
// If structuredValue is empty/invalid, parse from combinedValue
if (!this.structuredValue || !this.structuredValue.value) {
this.parseFromCombined();
this.structuredValue = this.toStructured();
} else {
// Use existing structuredValue
this.inputValue = this.structuredValue.value || '';
this.selectValue = this.structuredValue.unit || config.defaultUnit;
}
// Watch for external changes to combinedValue (from Livewire)
// Watch combinedValue for external changes
this.$watch('combinedValue', (newVal) => {
if (newVal !== this.combined()) {
this.parseValue(newVal);
const current = this.toStructured();
const parsed = this.parseCombinedValue(newVal);
if (current.value !== parsed.value || current.unit !== parsed.unit) {
this.parseFromCombined();
this.structuredValue = this.toStructured();
}
});
// Watch structuredValue for external changes
this.$watch('structuredValue', (newVal) => {
if (newVal && newVal.value !== undefined) {
const current = this.toStructured();
if (current.value !== newVal.value || current.unit !== newVal.unit) {
this.inputValue = newVal.value || '';
this.selectValue = newVal.unit || config.defaultUnit;
this.updateCombined();
}
}
}, { deep: true });
},
destroy() {
@ -101,19 +128,32 @@ document.addEventListener('alpine:init', () => {
}
},
parseValue(val) {
if (!val || val === '0') {
this.inputValue = '';
this.selectValue = config.defaultUnit;
return;
parseFromCombined() {
const parsed = this.parseCombinedValue(this.combinedValue || '');
this.inputValue = parsed.value;
this.selectValue = parsed.unit;
},
parseCombinedValue(combined) {
// Parse combinedValue (e.g., "512m") into structured format
// Only matches if suffix is a valid unit to avoid footguns
if (!combined || combined === '0') {
return { value: '', unit: config.defaultUnit };
}
const match = String(val).match(/^([\d.]+)\s*(.*)$/);
if (match) {
this.inputValue = match[1];
this.selectValue = match[2] || config.defaultUnit;
} else {
this.inputValue = val;
// Try to match valid unit suffix (longest first to handle multi-char units if needed)
const sortedUnits = [...config.validUnits].sort((a, b) => b.length - a.length);
for (const unit of sortedUnits) {
if (combined.endsWith(unit)) {
const value = combined.slice(0, -unit.length);
if (value && !isNaN(parseFloat(value))) {
return { value, unit };
}
}
}
// No valid unit found, treat entire value as number with default unit
return { value: combined, unit: config.defaultUnit };
},
validateAndClamp() {
@ -126,27 +166,38 @@ document.addEventListener('alpine:init', () => {
}
},
combined() {
if (!this.inputValue) return '0';
return this.inputValue + this.selectValue;
toStructured() {
return {
value: this.inputValue || '',
unit: this.selectValue || config.defaultUnit
};
},
updateCombined() {
const structured = this.toStructured();
if (!structured.value) {
this.combinedValue = '0';
} else {
this.combinedValue = structured.value + structured.unit;
}
},
updateStructured() {
this.validateAndClamp();
this.combinedValue = this.combined();
this.structuredValue = this.toStructured();
this.updateCombined();
},
handleInputChange() {
// Validate/clamp immediately as user types
this.validateAndClamp();
// Debounce the combined value update
if (this.pendingSync) {
clearTimeout(this.pendingSync);
}
this.pendingSync = setTimeout(() => {
if (this.pendingSync !== null && document.activeElement === this.$refs.input) {
this.combinedValue = this.combined();
this.structuredValue = this.toStructured();
this.updateCombined();
}
this.pendingSync = null;
}, 500);
@ -162,7 +213,7 @@ document.addEventListener('alpine:init', () => {
if (relatedTarget && container && container.contains(relatedTarget)) {
return;
}
this.updateCombined();
this.updateStructured();
}
}));
});