refactor: reimplement command palette using a custom overlay instead of the native dialog element

This commit is contained in:
pbao9
2026-02-15 02:52:20 +07:00
parent ab8831b71a
commit 046f61df7e
7 changed files with 345 additions and 122 deletions
+11 -2
View File
@@ -7,6 +7,7 @@
"dependencies": {
"animate.css": "^4.1.1",
"cmdk": "^1.1.1",
"datatables.net-colreorder": "^2.1.2",
"flyonui": "^2.1.0",
"laravel-echo": "^1.16.1",
"micromodal": "^0.6.1",
@@ -5174,7 +5175,6 @@
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.6.tgz",
"integrity": "sha512-xQ/dCxrjfxM0XY70wSIzakkTZ6ghERwlLmAPyCnu8Sk5cyt9YvOVyOsFNOa/BZ/lM63Q3i2YSSvp/o7GXZGsbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jquery": ">=1.7"
@@ -5191,6 +5191,16 @@
"jquery": ">=1.7"
}
},
"node_modules/datatables.net-colreorder": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/datatables.net-colreorder/-/datatables.net-colreorder-2.1.2.tgz",
"integrity": "sha512-lIsUyOt2nBm4sD2cSzDKZcIVrGgrZkh90Z2f03s8p7DYcZSfXMHAhFBrDYf9/eAK6wJnODN8EDMsrtPHfgoSXA==",
"license": "MIT",
"dependencies": {
"datatables.net": "^2",
"jquery": ">=1.7"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -7540,7 +7550,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
"dev": true,
"license": "MIT"
},
"node_modules/jquery-mousewheel": {
+1
View File
@@ -71,6 +71,7 @@
"dependencies": {
"animate.css": "^4.1.1",
"cmdk": "^1.1.1",
"datatables.net-colreorder": "^2.1.2",
"flyonui": "^2.1.0",
"laravel-echo": "^1.16.1",
"micromodal": "^0.6.1",
@@ -6,6 +6,8 @@
'orderable' => $c['orderable'] ?? true,
'searchable' => $c['searchable'] ?? true,
'visible' => $c['visible'] ?? true,
'key' => $c['key'],
'label' => $c['label'],
];
})->values();
@endphp
@@ -29,40 +31,106 @@
</div>
<div class="offcanvas offcanvas-end" tabindex="-1" id="{{ $id }}-column-offcanvas">
<div class="offcanvas-header">
<h5 class="offcanvas-title">Column Visibility</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
@foreach($columns as $index => $col)
@if((!isset($col['visibility']) || $col['visibility'] !== false) && $col['key'] !== 'checkbox')
<div class="form-check form-switch mb-2">
<input type="checkbox" class="form-check-input column-toggle"
data-column-index="{{ $index }}"
id="{{ $id }}-col-{{ $index }}"
{{ !isset($col['visible']) || $col['visible'] !== false ? 'checked' : '' }}>
<label class="form-check-label" for="{{ $id }}-col-{{ $index }}">{!! $col['label'] !!}</label>
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title">Column Visibility</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<p class="text-muted small mb-3"><i class="ti ti-arrows-sort me-1"></i> Drag to reorder columns</p>
<div id="{{ $id }}-column-list">
@foreach($columns as $index => $col)
@if((!isset($col['visibility']) || $col['visibility'] !== false) && $col['key'] !== 'checkbox')
<div class="form-check form-switch mb-2 p-2 rounded cursor-move column-item" data-column-key="{{ $col['key'] }}" data-column-index="{{ $index }}">
<input type="checkbox" class="form-check-input column-toggle"
data-column-key="{{ $col['key'] }}"
data-column-index="{{ $index }}"
id="{{ $id }}-col-{{ $index }}"
{{ !isset($col['visible']) || $col['visible'] !== false ? 'checked' : '' }}>
<label class="form-check-label user-select-none" for="{{ $id }}-col-{{ $index }}">{{ $col['label'] }}</label>
</div>
@endif
@endforeach
</div>
@endif
@endforeach
</div>
</div>
</div>
@push('custom-scripts')
<script src="{{ asset('build/plugins/sortablejs/Sortable.min.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var dtColumns = @json($dtColumns);
var table = $('#{{ $id }}').DataTable({
processing: true,
serverSide: true,
ajax: '{{ $ajax }}',
columns: @json($dtColumns),
columns: dtColumns,
colReorder: true,
});
$('#{{ $id }}-column-offcanvas').on('change', '.column-toggle', function () {
var columnIdx = $(this).data('column-index');
var column = table.column(columnIdx);
column.visible($(this).is(':checked'));
var columnKey = $(this).data('column-key');
var colIdx = -1;
dtColumns.forEach(function(col, idx) {
if (col.key === columnKey) {
colIdx = idx;
return;
}
});
if (colIdx >= 0) {
var column = table.column(colIdx);
column.visible($(this).is(':checked'));
}
});
var sortableInstance = null;
function initSortable() {
var sortableList = document.getElementById('{{ $id }}-column-list');
if (!sortableList) {
console.log('Sortable list not found');
return;
}
if (sortableInstance) {
sortableInstance.destroy();
}
console.log('Initializing Sortable');
sortableInstance = new Sortable(sortableList, {
animation: 150,
ghostClass: 'bg-light',
onEnd: function (evt) {
var items = sortableList.querySelectorAll('.column-item');
var newOrder = [];
items.forEach(function(item) {
newOrder.push(parseInt(item.dataset.columnIndex));
});
console.log('New order:', newOrder);
table.colReorder.order(newOrder);
}
});
}
var offcanvasEl = document.getElementById('{{ $id }}-column-offcanvas');
if (offcanvasEl) {
offcanvasEl.addEventListener('shown.bs.offcanvas', function() {
console.log('Offcanvas shown, initializing sortable');
initSortable();
});
}
});
</script>
<style>
.column-item {
cursor: move;
transition: background-color 0.15s;
}
.column-item:hover {
background-color: #f8f9fa;
}
.column-item:active {
cursor: moving;
background-color: #e9ecef;
}
</style>
@endpush
@@ -24,111 +24,113 @@ extractRoutes($sidebar, $menuItems);
$validMenuItems = collect($menuItems)->filter(fn($item) => Route::has($item['route']))->values();
@endphp
<dialog id="cmdk-dialog" class="cmdk-dialog">
<div class="cmdk">
<input type="text" class="cmdk-input" placeholder="Search pages...">
<div class="cmdk-list">
@forelse($validMenuItems as $item)
<a href="{{ route($item['route'], $item['params']) }}"
class="cmdk-item"
data-value="{{ strtolower($item['title']) }}">
<span>{{ $item['title'] }}</span>
@if($item['group'])
<span class="cmdk-group">{{ $item['group'] }}</span>
@endif
</a>
@empty
<div class="cmdk-empty">No pages found</div>
@endforelse
<div id="command-palette-container" style="display: none;">
<div class="command-palette-backdrop" id="cmdk-backdrop"></div>
<div class="command-palette-wrapper" id="cmdk-wrapper">
<div class="cmdk">
<input type="text" class="cmdk-input" placeholder="Search pages..." id="cmdk-input">
<div class="cmdk-list" id="cmdk-list">
@forelse($validMenuItems as $item)
<a href="{{ route($item['route'], $item['params']) }}"
class="cmdk-item"
data-value="{{ strtolower($item['title']) }}">
<span>{{ $item['title'] }}</span>
@if($item['group'])
<span class="cmdk-group">{{ $item['group'] }}</span>
@endif
</a>
@empty
<div class="cmdk-empty">No pages found</div>
@endforelse
</div>
</div>
</div>
</dialog>
</div>
@push('custom-scripts')
<script>
(function() {
const searchInput = document.getElementById('navbarForm');
const dialog = document.getElementById('cmdk-dialog');
var container = document.getElementById('command-palette-container');
var backdrop = document.getElementById('cmdk-backdrop');
var wrapper = document.getElementById('cmdk-wrapper');
var input = document.getElementById('cmdk-input');
var searchInput = document.getElementById('navbarForm');
var cmdkItems = document.querySelectorAll('.cmdk-item');
console.log('Search input:', searchInput);
console.log('Dialog:', dialog);
if (!searchInput) {
console.error('navbarForm not found');
return;
}
if (!dialog) {
console.error('cmdk-dialog not found');
return;
}
const cmdkInput = dialog.querySelector('.cmdk-input');
const cmdkItems = dialog.querySelectorAll('.cmdk-item');
console.log('CMDK items:', cmdkItems.length);
searchInput.addEventListener('focus', function() {
console.log('Focus triggered');
dialog.showModal();
});
searchInput.addEventListener('input', function(e) {
const query = e.target.value.toLowerCase();
console.log('Search query:', query);
if (!dialog.hasAttribute('open')) {
dialog.showModal();
}
cmdkItems.forEach(function(item) {
const value = item.dataset.value || item.textContent.toLowerCase();
const match = query === '' || value.includes(query);
item.style.display = match ? '' : 'none';
});
});
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
dialog.showModal();
cmdkInput.focus();
}
});
dialog.addEventListener('click', function(e) {
if (e.target === dialog) {
dialog.close();
searchInput.blur();
}
});
dialog.addEventListener('close', function() {
searchInput.value = '';
function showPalette() {
container.style.display = 'block';
input.value = searchInput ? searchInput.value : '';
input.focus();
cmdkItems.forEach(function(item) {
item.style.display = '';
});
}
function hidePalette() {
container.style.display = 'none';
if (searchInput) searchInput.blur();
}
if (searchInput) {
searchInput.addEventListener('focus', showPalette);
searchInput.addEventListener('input', function(e) {
if (container.style.display === 'none') {
showPalette();
}
var query = e.target.value.toLowerCase();
cmdkItems.forEach(function(item) {
var value = item.dataset.value || item.textContent.toLowerCase();
item.style.display = (query === '' || value.includes(query)) ? '' : 'none';
});
});
}
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
showPalette();
}
if (e.key === 'Escape' && container.style.display === 'block') {
hidePalette();
}
});
if (backdrop) {
backdrop.addEventListener('click', hidePalette);
}
})();
</script>
<style>
.cmdk-dialog {
padding: 0;
border: none;
background: transparent;
max-width: 700px;
#command-palette-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
border-radius: 12px;
z-index: 1056;
height: 100%;
z-index: 9999;
}
.cmdk-dialog::backdrop {
.command-palette-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.command-palette-wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
max-width: 700px;
padding: 0 20px;
}
.cmdk {
background: var(--bs-body-bg, #fff);
border-radius: 12px;
@@ -166,8 +168,7 @@ $validMenuItems = collect($menuItems)->filter(fn($item) => Route::has($item['rou
transition: background 0.15s;
}
.cmdk-item:hover,
.cmdk-item[data-selected="true"] {
.cmdk-item:hover {
background: var(--bs-tertiary-bg, #f1f5f9);
}
@@ -195,8 +196,7 @@ $validMenuItems = collect($menuItems)->filter(fn($item) => Route::has($item['rou
color: #f8fafc;
}
[data-bs-theme="dark"] .cmdk-item:hover,
[data-bs-theme="dark"] .cmdk-item[data-selected="true"] {
[data-bs-theme="dark"] .cmdk-item:hover {
background: #334155;
}
</style>
+145 -12
View File
@@ -119,22 +119,29 @@ $dtColumns = collect($columns)
<h5 class="offcanvas-title">Column Visibility</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
@foreach($columns as $index => $col)
@if((!isset($col['visibility']) || $col['visibility'] !== false) && $col['key'] !== 'checkbox')
<div class="form-check form-switch mb-2">
<input type="checkbox" class="form-check-input column-toggle"
data-column-index="{{ $index }}"
id="{{ $id }}-col-{{ $index }}"
{{ !isset($col['visible']) || $col['visible'] !== false ? 'checked' : '' }}>
<label class="form-check-label" for="{{ $id }}-col-{{ $index }}">{{ $col['label'] }}</label>
</div>
@endif
@endforeach
<div class="offcanvas-body p-3">
<div id="{{ $id }}-column-list">
@foreach($columns as $index => $col)
@if((!isset($col['visibility']) || $col['visibility'] !== false) && $col['key'] !== 'checkbox')
<div class="d-flex align-items-center gap-2 mb-2 p-2 rounded column-item bg-white border" data-column-key="{{ $col['key'] }}" data-column-index="{{ $index }}" style="cursor: grab; transition: all 0.15s ease;">
<i data-lucide="grip-vertical" class="text-muted" style="width: 16px; height: 16px; flex-shrink: 0;"></i>
<div class="form-check form-switch mb-0 flex-grow-1">
<input type="checkbox" class="form-check-input column-toggle"
data-column-key="{{ $col['key'] }}"
data-column-index="{{ $index }}"
id="{{ $id }}-col-{{ $index }}"
{{ !isset($col['visible']) || $col['visible'] !== false ? 'checked' : '' }}>
<label class="form-check-label user-select-none" for="{{ $id }}-col-{{ $index }}">{{ $col['label'] }}</label>
</div>
</div>
@endif
@endforeach
</div>
</div>
</div>
@push('custom-scripts')
<script src="{{ asset('build/plugins/sortablejs/Sortable.min.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
$.fn.DataTable.ext.pager.numbers_length = 7;
@@ -235,15 +242,118 @@ $dtColumns = collect($columns)
lucide.createIcons();
}
}, 50);
var storedOrder = getStoredOrder();
if (storedOrder && storedOrder.length > 0) {
var $table = $('#{{ $id }}');
var $tbody = $table.find('tbody');
var $rows = $tbody.find('tr');
if ($rows.length > 0) {
$rows.each(function() {
var cells = $(this).find('td').toArray();
var newCells = storedOrder.map(function(idx) { return cells[idx]; });
$(this).find('td').remove();
newCells.forEach(function(td) { $(this).append(td); }, $(this));
});
}
}
}
});
var sortableInstance = null;
var tableId = '{{ $id }}';
var storageKey = 'dt_column_order_' + tableId;
function getStoredOrder() {
var stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : null;
}
function saveOrderToStorage(newOrder) {
localStorage.setItem(storageKey, JSON.stringify(newOrder));
}
function reorderTableColumns(newOrder) {
saveOrderToStorage(newOrder);
var $table = $('#{{ $id }}');
var $theadRows = $table.find('thead tr');
$theadRows.each(function() {
var headers = $(this).find('th').toArray();
var newHeaders = newOrder.map(function(idx) { return headers[idx]; });
$(this).find('th').remove();
newHeaders.forEach(function(th) { $(this).append(th); }, $(this));
});
var $tbody = $table.find('tbody');
var $rows = $tbody.find('tr');
if ($rows.length > 0) {
$rows.each(function() {
var cells = $(this).find('td').toArray();
var newCells = newOrder.map(function(idx) { return cells[idx]; });
$(this).find('td').remove();
newCells.forEach(function(td) { $(this).append(td); }, $(this));
});
}
reorderOffcanvasList(newOrder);
}
function reorderOffcanvasList(newOrder) {
var $list = $('#{{ $id }}-column-list');
var items = $list.find('.column-item').toArray();
var newItems = newOrder.map(function(idx) { return items[idx]; });
$list.find('.column-item').remove();
newItems.forEach(function(item) { $list.append(item); });
}
function applyStoredOrder() {
var storedOrder = getStoredOrder();
if (storedOrder && storedOrder.length > 0) {
reorderTableColumns(storedOrder);
}
}
applyStoredOrder();
var sortableInstance = null;
function initSortable() {
var sortableList = document.getElementById('{{ $id }}-column-list');
if (!sortableList) return;
sortableInstance = new Sortable(sortableList, {
animation: 150,
ghostClass: 'bg-light',
delay: 100,
onEnd: function (evt) {
var items = sortableList.querySelectorAll('.column-item');
var newOrder = [];
items.forEach(function(item) {
newOrder.push(parseInt(item.dataset.columnIndex));
});
reorderTableColumns(newOrder);
}
});
}
$('#{{ $id }}-column-offcanvas').on('change', '.column-toggle', function () {
var columnKey = $(this).data('column-key');
var columnIdx = $(this).data('column-index');
var column = table.column(columnIdx);
column.visible($(this).is(':checked'));
});
var offcanvasEl = document.getElementById('{{ $id }}-column-offcanvas');
if (offcanvasEl) {
offcanvasEl.addEventListener('shown.bs.offcanvas', function() {
initSortable();
if (window.lucide) lucide.createIcons();
});
}
$('#{{ $id }} tbody').on('dblclick', 'tr', function () {
const editLink = $(this).find('a.btn-outline-primary, a[href*="edit"]').first();
if (editLink.length && editLink.attr('href')) {
@@ -554,5 +664,28 @@ $dtColumns = collect($columns)
gap: 0.5rem;
margin: 0;
}
.column-item {
cursor: grab !important;
}
.column-item:hover {
background-color: #f8f9fa !important;
border-color: #dee2e6 !important;
}
.column-item:active {
background-color: #e9ecef !important;
cursor: grabbing !important;
}
.column-item.sortable-ghost {
background-color: #e3f2fd !important;
opacity: 0.8;
}
.column-item.sortable-chosen {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
@endpush
+4
View File
@@ -205,6 +205,10 @@ export default defineConfig({
src: ['node_modules/sortablejs/Sortable.min.js'],
dest: 'plugins/sortablejs',
},
{
src: ['node_modules/datatables.net-colreorder/js/dataTables.colReorder.min.js'],
dest: 'plugins/datatables.net-colreorder',
},
{
src: [
'node_modules/sweetalert2/dist/sweetalert2.min.js',
+9 -1
View File
@@ -2839,7 +2839,15 @@ datatables.net-bs5@^2.3.6:
datatables.net "2.3.6"
jquery ">=1.7"
datatables.net@^2.3.6, datatables.net@2.3.6:
datatables.net-colreorder@^2.1.2:
version "2.1.2"
resolved "https://registry.npmjs.org/datatables.net-colreorder/-/datatables.net-colreorder-2.1.2.tgz"
integrity sha512-lIsUyOt2nBm4sD2cSzDKZcIVrGgrZkh90Z2f03s8p7DYcZSfXMHAhFBrDYf9/eAK6wJnODN8EDMsrtPHfgoSXA==
dependencies:
datatables.net "^2"
jquery ">=1.7"
datatables.net@^2, datatables.net@^2.3.6, datatables.net@2.3.6:
version "2.3.6"
resolved "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.6.tgz"
integrity sha512-xQ/dCxrjfxM0XY70wSIzakkTZ6ghERwlLmAPyCnu8Sk5cyt9YvOVyOsFNOa/BZ/lM63Q3i2YSSvp/o7GXZGsbg==