mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(ui): soften list separators and fix the page size control
- Row and section separators use a softer translucent divider token on all themes
- Page size trigger is an aligned bordered button ("10 / page")
- Pagination footer stays visible when a larger page size fits all rows
- Custom page size warns above the 100 maximum and the menu closes on select
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4e6c0bfcb5
commit
32427a20f6
@@ -1348,6 +1348,9 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
|
||||
/* Card/table edge ring. Crisped from 93.5% (1.17:1 on canvas, invisible)
|
||||
to 85.5% (1.51:1) so surfaces read as bordered cards, not flat fills. */
|
||||
--coollabs-hairline: oklch(85.5% 0 0);
|
||||
/* Row and section separators inside cards. Softer than the card edge.
|
||||
Translucent, so it stays visible on any card or theme surface. */
|
||||
--coollabs-divider: oklch(0% 0 0 / 0.04);
|
||||
/* Muted labels/titles. Darkened from 55.6% (failed WCAG AA on elevated
|
||||
4.46, recessed 4.24, fill 3.76) to 50% so muted text clears 4.5:1 on
|
||||
every light surface above white. Dark stays 70.8% (already passes). */
|
||||
@@ -1386,6 +1389,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
|
||||
/* Kept about 1.6:1 on the canvas so dark cards
|
||||
and tables show a visible edge instead of blending into the canvas. */
|
||||
--coollabs-hairline: oklch(36% 0 0);
|
||||
--coollabs-divider: oklch(100% 0 0 / 0.045);
|
||||
--coollabs-subtle: oklch(70.8% 0 0);
|
||||
/* Neutral (was #a8a8b0 / #7a7a84, both cool-tinted). Sidebar text now shares
|
||||
the same neutral temperature as the content text, so the sidebar no longer
|
||||
@@ -1492,6 +1496,7 @@ html[data-theme="custom"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-s
|
||||
--coollabs-fill: color-mix(in oklab, var(--t-text) calc(8% + (1 - var(--t-light)) * 4%), var(--t-bg));
|
||||
--coollabs-line: color-mix(in oklab, var(--t-text) calc(16% + (1 - var(--t-light)) * 6%), var(--t-bg));
|
||||
--coollabs-hairline: color-mix(in oklab, var(--t-text) calc(11% + (1 - var(--t-light)) * 3%), var(--t-bg));
|
||||
--coollabs-divider: color-mix(in oklab, var(--t-text) calc(5% + (1 - var(--t-light)) * 1%), transparent);
|
||||
--color-hairline: color-mix(in oklab, var(--t-text) 8%, transparent);
|
||||
--color-fg: var(--t-text);
|
||||
--color-fg-dim: color-mix(in oklab, var(--t-text) 74%, var(--t-bg));
|
||||
@@ -1626,6 +1631,16 @@ html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobil
|
||||
border-color: var(--coollabs-line) !important;
|
||||
}
|
||||
|
||||
/* Row and section separators (one-sided borders and divide-y) use the softer
|
||||
divider token, so lists read as one card instead of a stack of boxes. Card
|
||||
edges (full borders) keep the hairline/line tokens. */
|
||||
html:not(.dark) :is([class~="border-b"], [class~="border-t"], [class~="border-y"])[class~="border-neutral-200"],
|
||||
html:not(.dark) [class~="divide-neutral-200"] > :not(:last-child),
|
||||
.dark :is([class~="border-b"], [class~="border-t"], [class~="border-y"]):is([class~="dark:border-white/[0.06]"], [class~="dark:border-white/[0.07]"], [class~="dark:border-white/[0.08]"]),
|
||||
.dark :is([class~="dark:divide-white/[0.06]"], [class~="dark:divide-white/[0.07]"], [class~="dark:divide-white/[0.08]"]) > :not(:last-child) {
|
||||
border-color: var(--coollabs-divider) !important;
|
||||
}
|
||||
|
||||
html[data-theme="custom"] input:read-only,
|
||||
html[data-theme="custom"] textarea:read-only,
|
||||
html[data-theme="custom"] input:disabled,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@props([
|
||||
'summary',
|
||||
'total',
|
||||
'pageSizeModel',
|
||||
'storageKey',
|
||||
'options' => [10, 25, 50, 100],
|
||||
@@ -9,7 +10,7 @@
|
||||
'nextDisabled' => 'page >= totalPages',
|
||||
])
|
||||
|
||||
<footer x-bind:hidden="Number(totalPages) <= 1" {{ $attributes->class('pager flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
|
||||
<footer x-bind:hidden="Number({{ $total }}) <= {{ (int) min($options) }}" {{ $attributes->class('pager flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-7 items-center whitespace-nowrap tabular-nums" x-text="{{ $summary }}"></span>
|
||||
<x-page-size-select :model="$pageSizeModel" :storage-key="$storageKey" :options="$options" />
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
customPageSize: @js((int) $options[0]),
|
||||
customizingPageSize: false,
|
||||
applyPageSize(value) {
|
||||
if (value === '' || value === null) {
|
||||
this.customizingPageSize = false;
|
||||
return;
|
||||
}
|
||||
if (Number(value) > 100) {
|
||||
window.toast?.('Maximum is 100 rows per page.', { type: 'warning' });
|
||||
}
|
||||
const pageSizeValue = Math.min(100, Math.max(1, Number(value) || 1));
|
||||
this.selectedPageSize = pageSizeValue;
|
||||
this.customPageSize = pageSizeValue;
|
||||
@@ -39,35 +46,36 @@
|
||||
if (savedPageSize >= 1 && savedPageSize <= 100) applyPageSize(savedPageSize);
|
||||
@endif
|
||||
">
|
||||
<span x-show="!customizingPageSize" class="relative inline-flex h-7 w-12 items-center">
|
||||
<div x-show="!customizingPageSize">
|
||||
<x-table.dropdown panel-class="min-w-24!">
|
||||
<x-slot:trigger>
|
||||
<button type="button" aria-label="Items per page" aria-haspopup="listbox" :aria-expanded="open"
|
||||
@disabled($disabled)
|
||||
class="inline-flex h-7! w-12! items-center justify-between border-0 px-1 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors hover:text-black dark:text-fg-dim dark:hover:text-fg">
|
||||
class="flex h-7! items-center gap-1 whitespace-nowrap rounded-md border border-neutral-200 pr-1.5 pl-2 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<span x-text="selectedPageSize"></span>
|
||||
<x-reicon name="chevron-down" class="size-3 text-neutral-400 dark:text-fg-faint" />
|
||||
<span class="text-neutral-400 dark:text-fg-faint">/ page</span>
|
||||
<x-reicon name="chevron-down" class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
@foreach ($options as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
:aria-selected="selectedPageSize === {{ (int) $option }}"
|
||||
x-on:click="applyPageSize({{ (int) $option }})">
|
||||
x-on:click="close(); applyPageSize({{ (int) $option }})">
|
||||
<span>{{ $option }}</span>
|
||||
<x-reicon x-show="selectedPageSize === {{ (int) $option }}" name="check" class="size-3.5" />
|
||||
</button>
|
||||
@endforeach
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
x-on:click="customizingPageSize = true; $nextTick(() => $refs.customPageSize.focus())">
|
||||
x-on:click="close(); customPageSize = ''; customizingPageSize = true; $nextTick(() => $refs.customPageSize.focus())">
|
||||
Custom…
|
||||
</button>
|
||||
</x-table.dropdown>
|
||||
</span>
|
||||
</div>
|
||||
<input x-cloak x-show="customizingPageSize" x-ref="customPageSize" x-model.number="customPageSize"
|
||||
x-on:keydown.enter.prevent="applyPageSize(customPageSize)" x-on:keydown.escape.prevent="customizingPageSize = false"
|
||||
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" inputmode="numeric"
|
||||
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" placeholder="1–100" inputmode="numeric"
|
||||
aria-label="Custom items per page" @disabled($disabled)
|
||||
class="mb-0! h-7! w-14! rounded-md! border-neutral-200! bg-transparent! px-1.5! py-0! text-[11px]! tabular-nums shadow-none! focus:border-neutral-300! focus:ring-0! dark:border-white/[0.08]! dark:text-fg-dim!" />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
'total' => 0,
|
||||
'currentPage' => 1,
|
||||
'lastPage' => 1,
|
||||
/** Smallest page size the page-size slot offers */
|
||||
'minPageSize' => 10,
|
||||
/** Comma-separated Livewire actions that should show the loading state */
|
||||
'wireTarget' => null,
|
||||
'firstAction' => null,
|
||||
@@ -18,9 +20,11 @@
|
||||
$buttonClass =
|
||||
'flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg';
|
||||
$hasLoading = filled($wireTarget);
|
||||
// Keep the footer while a larger page size hides the pages, so the user can change it back.
|
||||
$hidden = isset($pageSize) ? (int) $total <= (int) $minPageSize : (int) $lastPage <= 1;
|
||||
@endphp
|
||||
|
||||
<footer @if ((int) $lastPage <= 1) hidden @endif {{ $attributes->class('pager flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
|
||||
<footer @if ($hidden) hidden @endif {{ $attributes->class('pager flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-7 items-center whitespace-nowrap tabular-nums">{{ $from }}–{{ $to }} of {{ $total }}</span>
|
||||
@isset($pageSize)
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<x-client-pagination x-show="filteredProjects.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]"
|
||||
<x-client-pagination total="filteredProjects.length" x-show="filteredProjects.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]"
|
||||
summary="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`" page-size-model="pageSize"
|
||||
storage-key="coolify.page-size.projects" :options="[12, 24, 48, 96]" />
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<x-client-pagination x-show="filteredProjects.length > 0"
|
||||
<x-client-pagination total="filteredProjects.length" x-show="filteredProjects.length > 0"
|
||||
summary="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`" page-size-model="pageSize"
|
||||
storage-key="coolify.page-size.projects" :options="[12, 24, 48, 96]" />
|
||||
</div>
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
Try a different search or filter.
|
||||
</p>
|
||||
</div>
|
||||
<x-client-pagination x-show="filteredResources.length > 0"
|
||||
<x-client-pagination total="filteredResources.length" x-show="filteredResources.length > 0"
|
||||
summary="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"
|
||||
page-size-model="pageSize" storage-key="coolify.page-size.environment-resources" />
|
||||
</div>
|
||||
@@ -309,7 +309,7 @@
|
||||
Try a different search or filter.
|
||||
</p>
|
||||
</div>
|
||||
<x-client-pagination x-show="filteredResources.length > 0"
|
||||
<x-client-pagination total="filteredResources.length" x-show="filteredResources.length > 0"
|
||||
class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]"
|
||||
summary="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"
|
||||
page-size-model="pageSize" storage-key="coolify.page-size.environment-resources" />
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<x-client-pagination x-show="filteredEnvironments.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]"
|
||||
<x-client-pagination total="filteredEnvironments.length" x-show="filteredEnvironments.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]"
|
||||
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
|
||||
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
|
||||
</div>
|
||||
@@ -190,7 +190,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<x-client-pagination x-show="filteredEnvironments.length > 0"
|
||||
<x-client-pagination total="filteredEnvironments.length" x-show="filteredEnvironments.length > 0"
|
||||
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
|
||||
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
description="Try a different description or permission." />
|
||||
</div>
|
||||
|
||||
<x-client-pagination x-cloak x-show="filteredTokens.length > 0"
|
||||
<x-client-pagination total="filteredTokens.length" x-cloak x-show="filteredTokens.length > 0"
|
||||
summary="`${firstVisibleRow}-${lastVisibleRow} of ${filteredTokens.length}`"
|
||||
page-size-model="perPage" storage-key="coolify.page-size.api-tokens"
|
||||
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
description="Try a different name, email address, or role." />
|
||||
</div>
|
||||
|
||||
<x-client-pagination x-cloak x-show="filteredMembers.length > 0"
|
||||
<x-client-pagination total="filteredMembers.length" x-cloak x-show="filteredMembers.length > 0"
|
||||
summary="`${firstVisibleRow}-${lastVisibleRow} of ${filteredMembers.length}`"
|
||||
page-size-model="perPage" storage-key="coolify.page-size.team-members"
|
||||
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
|
||||
|
||||
@@ -68,3 +68,23 @@ it('omits livewire loading markup when no wire target is provided', function ()
|
||||
->not->toContain('wire:target')
|
||||
->not->toContain('Loading page…');
|
||||
});
|
||||
|
||||
it('keeps the footer on one page while a larger page size is selected', function (int $total, bool $hidden) {
|
||||
$html = Blade::render(
|
||||
'<x-table-pagination :from="1" :to="$total" :total="$total" :current-page="1" :last-page="1">
|
||||
<x-slot:pageSize><x-page-size-select model="perPage" livewire /></x-slot:pageSize>
|
||||
</x-table-pagination>',
|
||||
['total' => $total]
|
||||
);
|
||||
|
||||
expect((bool) preg_match('/<footer[^>]*\shidden[\s>]/', $html))->toBe($hidden);
|
||||
})->with([
|
||||
'more rows than the smallest page size' => [15, false],
|
||||
'all rows fit in the smallest page size' => [10, true],
|
||||
]);
|
||||
|
||||
it('hides the footer on one page when there is no page size control', function () {
|
||||
$html = Blade::render('<x-table-pagination :from="1" :to="15" :total="15" :current-page="1" :last-page="1" />');
|
||||
|
||||
expect($html)->toMatch('/<footer[^>]*\shidden[\s>]/');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user