This commit is contained in:
parent
b394eca08b
commit
f111816acb
3 changed files with 391 additions and 341 deletions
|
|
@ -1,139 +1,9 @@
|
|||
{% load i18n %}
|
||||
<style>
|
||||
.plan-selection .plan-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle:hover,
|
||||
.plan-selection .plan-dropdown-toggle:focus {
|
||||
border-color: #a1afdf;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle[aria-expanded="true"] {
|
||||
border-color: #a1afdf;
|
||||
box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle .dropdown-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle[aria-expanded="true"] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
margin-top: -1px;
|
||||
background: #fff;
|
||||
border: 1px solid #a1afdf;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option:hover,
|
||||
.plan-selection .plan-option.focused {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option.selected {
|
||||
background-color: #f0f4ff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option.selected.focused {
|
||||
background-color: #e0e8ff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-content {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.plan-selection .plan-header h6 {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.plan-selection .badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
|
||||
.plan-selection .price-display {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-selection .plan-specs {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.plan-selection .form-check-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card .plan-content {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card .storage-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
color: #6c757d;
|
||||
.bi::before {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="plan-selection">
|
||||
{% load i18n static %}
|
||||
<link rel="stylesheet" href="{% static 'css/plan-selection.css' %}">
|
||||
<div class="plan-selection"
|
||||
data-radio-name="{{ plan_form.compute_plan_assignment.html_name }}"
|
||||
data-storage-price-per-gib="{{ storage_plan.price_per_gib|default:0 }}"
|
||||
data-per-hour-text="{% trans 'per hour' %}">
|
||||
{% if plan_form %}
|
||||
<!-- Hidden radio inputs for form submission -->
|
||||
<div style="display: none;">
|
||||
|
|
@ -253,208 +123,4 @@
|
|||
<div class="alert alert-warning">{% trans "No compute plans available for this service offering." %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
const planDropdownToggle = document.getElementById('plan-dropdown-toggle');
|
||||
const planDropdownMenu = document.getElementById('plan-dropdown-menu');
|
||||
const planOptions = Array.from(document.querySelectorAll('.plan-option'));
|
||||
const radioInputs = document.querySelectorAll('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]');
|
||||
let focusedIndex = -1;
|
||||
|
||||
// Update the display in the toggle button based on selected plan
|
||||
function updateSelectedDisplay(data) {
|
||||
document.getElementById('selected-plan-name').textContent = data.planName;
|
||||
document.getElementById('selected-plan-price').textContent = 'CHF ' + data.planPrice;
|
||||
document.getElementById('selected-plan-unit').textContent = data.planUnit;
|
||||
document.getElementById('selected-plan-cpu').textContent = data.cpuLimits;
|
||||
document.getElementById('selected-plan-memory').textContent = data.memoryLimits;
|
||||
|
||||
const slaBadge = document.getElementById('selected-plan-sla');
|
||||
slaBadge.textContent = data.planSlaDisplay;
|
||||
slaBadge.className = 'badge bg-' + (data.planSla === 'guaranteed' ? 'success' : 'secondary');
|
||||
}
|
||||
|
||||
// Update CPU/memory fields in the form
|
||||
function updateFormFields(data) {
|
||||
const cpuLimit = document.querySelector('input[name="expert-spec.parameters.size.cpu"]');
|
||||
const memoryLimit = document.querySelector('input[name="expert-spec.parameters.size.memory"]');
|
||||
const cpuRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.cpu"]');
|
||||
const memoryRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.memory"]');
|
||||
|
||||
if (cpuLimit) cpuLimit.value = data.cpuLimits;
|
||||
if (memoryLimit) memoryLimit.value = data.memoryLimits;
|
||||
if (cpuRequest) cpuRequest.value = data.cpuRequests;
|
||||
if (memoryRequest) memoryRequest.value = data.memoryRequests;
|
||||
}
|
||||
|
||||
// Select a plan by value
|
||||
function selectPlan(value) {
|
||||
// Update the hidden radio input
|
||||
radioInputs.forEach(radio => {
|
||||
radio.checked = (radio.value === value);
|
||||
});
|
||||
|
||||
// Update visual selection
|
||||
planOptions.forEach(option => {
|
||||
option.classList.toggle('selected', option.dataset.value === value);
|
||||
});
|
||||
|
||||
// Find selected option and update display
|
||||
const selectedOption = document.querySelector('.plan-option[data-value="' + value + '"]');
|
||||
if (selectedOption) {
|
||||
const data = {
|
||||
planName: selectedOption.dataset.planName,
|
||||
planSla: selectedOption.dataset.planSla,
|
||||
planSlaDisplay: selectedOption.dataset.planSlaDisplay,
|
||||
planPrice: selectedOption.dataset.planPrice,
|
||||
planUnit: selectedOption.dataset.planUnit,
|
||||
cpuLimits: selectedOption.dataset.cpuLimits,
|
||||
memoryLimits: selectedOption.dataset.memoryLimits,
|
||||
cpuRequests: selectedOption.dataset.cpuRequests,
|
||||
memoryRequests: selectedOption.dataset.memoryRequests
|
||||
};
|
||||
updateSelectedDisplay(data);
|
||||
updateFormFields(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Update focused option visually
|
||||
function updateFocusedOption(newIndex) {
|
||||
planOptions.forEach(opt => opt.classList.remove('focused'));
|
||||
if (newIndex >= 0 && newIndex < planOptions.length) {
|
||||
focusedIndex = newIndex;
|
||||
planOptions[focusedIndex].classList.add('focused');
|
||||
// Scroll into view if needed
|
||||
planOptions[focusedIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get index of currently selected option
|
||||
function getSelectedIndex() {
|
||||
return planOptions.findIndex(opt => opt.classList.contains('selected'));
|
||||
}
|
||||
|
||||
// Open dropdown
|
||||
function openDropdown() {
|
||||
planDropdownToggle.setAttribute('aria-expanded', 'true');
|
||||
planDropdownMenu.classList.add('show');
|
||||
// Set focus to selected option or first option
|
||||
const selectedIdx = getSelectedIndex();
|
||||
updateFocusedOption(selectedIdx >= 0 ? selectedIdx : 0);
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
function closeDropdown() {
|
||||
planDropdownToggle.setAttribute('aria-expanded', 'false');
|
||||
planDropdownMenu.classList.remove('show');
|
||||
planOptions.forEach(opt => opt.classList.remove('focused'));
|
||||
focusedIndex = -1;
|
||||
}
|
||||
|
||||
// Check if dropdown is open
|
||||
function isOpen() {
|
||||
return planDropdownToggle.getAttribute('aria-expanded') === 'true';
|
||||
}
|
||||
|
||||
// Toggle dropdown visibility
|
||||
function toggleDropdown() {
|
||||
if (isOpen()) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
if (planDropdownToggle) {
|
||||
planDropdownToggle.addEventListener('click', toggleDropdown);
|
||||
planDropdownToggle.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (isOpen() && focusedIndex >= 0) {
|
||||
selectPlan(planOptions[focusedIndex].dataset.value);
|
||||
closeDropdown();
|
||||
} else {
|
||||
toggleDropdown();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
closeDropdown();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen()) {
|
||||
openDropdown();
|
||||
} else {
|
||||
const newIndex = focusedIndex < planOptions.length - 1 ? focusedIndex + 1 : 0;
|
||||
updateFocusedOption(newIndex);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!isOpen()) {
|
||||
openDropdown();
|
||||
} else {
|
||||
const newIndex = focusedIndex > 0 ? focusedIndex - 1 : planOptions.length - 1;
|
||||
updateFocusedOption(newIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
planOptions.forEach((option, index) => {
|
||||
option.addEventListener('click', function() {
|
||||
selectPlan(this.dataset.value);
|
||||
closeDropdown();
|
||||
planDropdownToggle.focus();
|
||||
});
|
||||
option.addEventListener('mouseenter', function() {
|
||||
updateFocusedOption(index);
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (planDropdownToggle && !planDropdownToggle.contains(e.target) && !planDropdownMenu.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize with currently checked radio
|
||||
const checkedRadio = document.querySelector('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]:checked');
|
||||
if (checkedRadio) {
|
||||
selectPlan(checkedRadio.value);
|
||||
}
|
||||
|
||||
// Setup storage cost calculator
|
||||
function setupStorageCostCalculator() {
|
||||
const diskInput = document.getElementById('id_custom-spec.parameters.size.disk');
|
||||
if (diskInput && !diskInput.dataset.storageListenerAttached) {
|
||||
diskInput.dataset.storageListenerAttached = 'true';
|
||||
diskInput.addEventListener('input', function() {
|
||||
const sizeGiB = parseFloat(this.value) || 0;
|
||||
const pricePerGiB = {{ storage_plan.price_per_gib|default:0 }};
|
||||
const totalCost = (sizeGiB * pricePerGiB).toFixed(2);
|
||||
const display = document.getElementById('storage-cost-display');
|
||||
if (display && sizeGiB > 0) {
|
||||
display.innerHTML = '<i class="bi bi-calculator"></i> ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = <strong>CHF ' + totalCost + '</strong> {% trans "per hour" %}';
|
||||
} else if (display) {
|
||||
display.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger initial calculation if disk field has a value
|
||||
if (diskInput.value) {
|
||||
diskInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to setup immediately (in case form is already loaded)
|
||||
setupStorageCostCalculator();
|
||||
|
||||
// Also setup after HTMX swaps the form in
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form' || event.detail.target.id === 'control-plane-info') {
|
||||
setupStorageCostCalculator();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script defer src="{% static 'js/plan-selection.js' %}"></script>
|
||||
|
|
|
|||
140
src/servala/static/css/plan-selection.css
Normal file
140
src/servala/static/css/plan-selection.css
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
.plan-selection .plan-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle:hover {
|
||||
border-color: #a1afdf;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle:focus {
|
||||
color: #607080;
|
||||
background-color: #fff;
|
||||
border-color: #a1afdf;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25);
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle[aria-expanded="true"] {
|
||||
border-color: #a1afdf;
|
||||
box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle .dropdown-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-toggle[aria-expanded="true"] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
margin-top: -1px;
|
||||
background: #fff;
|
||||
border: 1px solid #a1afdf;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plan-selection .plan-dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option:hover,
|
||||
.plan-selection .plan-option.focused {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option.selected {
|
||||
background-color: #f0f4ff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-option.selected.focused {
|
||||
background-color: #e0e8ff;
|
||||
}
|
||||
|
||||
.plan-selection .plan-content {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.plan-selection .plan-header h6 {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.plan-selection .badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
|
||||
.plan-selection .price-display {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-selection .plan-specs {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.plan-selection .form-check-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card .plan-content {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card .storage-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.plan-selection .storage-card .storage-icon .bi::before {
|
||||
vertical-align: top;
|
||||
}
|
||||
244
src/servala/static/js/plan-selection.js
Normal file
244
src/servala/static/js/plan-selection.js
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
/**
|
||||
* Plan Selection Dropdown
|
||||
* A custom dropdown component for selecting compute plans with keyboard navigation.
|
||||
*/
|
||||
(function() {
|
||||
function initPlanSelection(container) {
|
||||
const planDropdownToggle = container.querySelector('#plan-dropdown-toggle');
|
||||
const planDropdownMenu = container.querySelector('#plan-dropdown-menu');
|
||||
const planOptions = Array.from(container.querySelectorAll('.plan-option'));
|
||||
const radioInputName = container.dataset.radioName;
|
||||
const radioInputs = container.querySelectorAll('input[name="' + radioInputName + '"]');
|
||||
let focusedIndex = -1;
|
||||
|
||||
if (!planDropdownToggle || planOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the display in the toggle button based on selected plan
|
||||
function updateSelectedDisplay(data) {
|
||||
const nameEl = container.querySelector('#selected-plan-name');
|
||||
const priceEl = container.querySelector('#selected-plan-price');
|
||||
const unitEl = container.querySelector('#selected-plan-unit');
|
||||
const cpuEl = container.querySelector('#selected-plan-cpu');
|
||||
const memoryEl = container.querySelector('#selected-plan-memory');
|
||||
const slaBadge = container.querySelector('#selected-plan-sla');
|
||||
|
||||
if (nameEl) nameEl.textContent = data.planName;
|
||||
if (priceEl) priceEl.textContent = 'CHF ' + data.planPrice;
|
||||
if (unitEl) unitEl.textContent = data.planUnit;
|
||||
if (cpuEl) cpuEl.textContent = data.cpuLimits;
|
||||
if (memoryEl) memoryEl.textContent = data.memoryLimits;
|
||||
|
||||
if (slaBadge) {
|
||||
slaBadge.textContent = data.planSlaDisplay;
|
||||
slaBadge.className = 'badge bg-' + (data.planSla === 'guaranteed' ? 'success' : 'secondary');
|
||||
}
|
||||
}
|
||||
|
||||
// Update CPU/memory fields in the form
|
||||
function updateFormFields(data) {
|
||||
const cpuLimit = document.querySelector('input[name="expert-spec.parameters.size.cpu"]');
|
||||
const memoryLimit = document.querySelector('input[name="expert-spec.parameters.size.memory"]');
|
||||
const cpuRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.cpu"]');
|
||||
const memoryRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.memory"]');
|
||||
|
||||
if (cpuLimit) cpuLimit.value = data.cpuLimits;
|
||||
if (memoryLimit) memoryLimit.value = data.memoryLimits;
|
||||
if (cpuRequest) cpuRequest.value = data.cpuRequests;
|
||||
if (memoryRequest) memoryRequest.value = data.memoryRequests;
|
||||
}
|
||||
|
||||
// Select a plan by value
|
||||
function selectPlan(value) {
|
||||
// Update the hidden radio input
|
||||
radioInputs.forEach(radio => {
|
||||
radio.checked = (radio.value === value);
|
||||
});
|
||||
|
||||
// Update visual selection
|
||||
planOptions.forEach(option => {
|
||||
option.classList.toggle('selected', option.dataset.value === value);
|
||||
});
|
||||
|
||||
// Find selected option and update display
|
||||
const selectedOption = container.querySelector('.plan-option[data-value="' + value + '"]');
|
||||
if (selectedOption) {
|
||||
const data = {
|
||||
planName: selectedOption.dataset.planName,
|
||||
planSla: selectedOption.dataset.planSla,
|
||||
planSlaDisplay: selectedOption.dataset.planSlaDisplay,
|
||||
planPrice: selectedOption.dataset.planPrice,
|
||||
planUnit: selectedOption.dataset.planUnit,
|
||||
cpuLimits: selectedOption.dataset.cpuLimits,
|
||||
memoryLimits: selectedOption.dataset.memoryLimits,
|
||||
cpuRequests: selectedOption.dataset.cpuRequests,
|
||||
memoryRequests: selectedOption.dataset.memoryRequests
|
||||
};
|
||||
updateSelectedDisplay(data);
|
||||
updateFormFields(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Update focused option visually
|
||||
function updateFocusedOption(newIndex) {
|
||||
planOptions.forEach(opt => opt.classList.remove('focused'));
|
||||
if (newIndex >= 0 && newIndex < planOptions.length) {
|
||||
focusedIndex = newIndex;
|
||||
planOptions[focusedIndex].classList.add('focused');
|
||||
// Scroll into view if needed
|
||||
planOptions[focusedIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get index of currently selected option
|
||||
function getSelectedIndex() {
|
||||
return planOptions.findIndex(opt => opt.classList.contains('selected'));
|
||||
}
|
||||
|
||||
// Open dropdown
|
||||
function openDropdown() {
|
||||
planDropdownToggle.setAttribute('aria-expanded', 'true');
|
||||
planDropdownMenu.classList.add('show');
|
||||
// Set focus to selected option or first option
|
||||
const selectedIdx = getSelectedIndex();
|
||||
updateFocusedOption(selectedIdx >= 0 ? selectedIdx : 0);
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
function closeDropdown() {
|
||||
planDropdownToggle.setAttribute('aria-expanded', 'false');
|
||||
planDropdownMenu.classList.remove('show');
|
||||
planOptions.forEach(opt => opt.classList.remove('focused'));
|
||||
focusedIndex = -1;
|
||||
}
|
||||
|
||||
// Check if dropdown is open
|
||||
function isOpen() {
|
||||
return planDropdownToggle.getAttribute('aria-expanded') === 'true';
|
||||
}
|
||||
|
||||
// Toggle dropdown visibility
|
||||
function toggleDropdown() {
|
||||
if (isOpen()) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
planDropdownToggle.addEventListener('click', toggleDropdown);
|
||||
planDropdownToggle.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (isOpen() && focusedIndex >= 0) {
|
||||
selectPlan(planOptions[focusedIndex].dataset.value);
|
||||
closeDropdown();
|
||||
} else {
|
||||
toggleDropdown();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
closeDropdown();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen()) {
|
||||
openDropdown();
|
||||
} else {
|
||||
const newIndex = focusedIndex < planOptions.length - 1 ? focusedIndex + 1 : 0;
|
||||
updateFocusedOption(newIndex);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!isOpen()) {
|
||||
openDropdown();
|
||||
} else {
|
||||
const newIndex = focusedIndex > 0 ? focusedIndex - 1 : planOptions.length - 1;
|
||||
updateFocusedOption(newIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
planOptions.forEach((option, index) => {
|
||||
option.addEventListener('click', function() {
|
||||
selectPlan(this.dataset.value);
|
||||
closeDropdown();
|
||||
planDropdownToggle.focus();
|
||||
});
|
||||
option.addEventListener('mouseenter', function() {
|
||||
updateFocusedOption(index);
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!planDropdownToggle.contains(e.target) && !planDropdownMenu.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize with currently checked radio
|
||||
const checkedRadio = container.querySelector('input[name="' + radioInputName + '"]:checked');
|
||||
if (checkedRadio) {
|
||||
selectPlan(checkedRadio.value);
|
||||
}
|
||||
}
|
||||
|
||||
function initStorageCostCalculator(container) {
|
||||
const pricePerGiB = parseFloat(container.dataset.storagePricePerGib) || 0;
|
||||
const perHourText = container.dataset.perHourText || 'per hour';
|
||||
|
||||
if (pricePerGiB === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const diskInput = document.getElementById('id_custom-spec.parameters.size.disk');
|
||||
if (diskInput && !diskInput.dataset.storageListenerAttached) {
|
||||
diskInput.dataset.storageListenerAttached = 'true';
|
||||
diskInput.addEventListener('input', function() {
|
||||
const sizeGiB = parseFloat(this.value) || 0;
|
||||
const totalCost = (sizeGiB * pricePerGiB).toFixed(2);
|
||||
const display = document.getElementById('storage-cost-display');
|
||||
if (display && sizeGiB > 0) {
|
||||
display.innerHTML = '<i class="bi bi-calculator"></i> ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = <strong>CHF ' + totalCost + '</strong> ' + perHourText;
|
||||
} else if (display) {
|
||||
display.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger initial calculation if disk field has a value
|
||||
if (diskInput.value) {
|
||||
diskInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to setup immediately (in case form is already loaded)
|
||||
setup();
|
||||
|
||||
// Also setup after HTMX swaps the form in
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form' || event.detail.target.id === 'control-plane-info') {
|
||||
setup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize all plan selection components on the page
|
||||
function init() {
|
||||
document.querySelectorAll('.plan-selection').forEach(function(container) {
|
||||
if (container.dataset.radioName) {
|
||||
initPlanSelection(container);
|
||||
initStorageCostCalculator(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue