Skip to content

Commit 3f48466

Browse files
committed
feat: implement bulk actions floating bar in place of pagination for checked rows
1 parent 4506aa0 commit 3f48466

7 files changed

Lines changed: 148 additions & 69 deletions

File tree

‎adminforth/design/system-concepts.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ Below `sm` (640px) a Dialog or Modal docks to the bottom edge as a sheet: full w
4848

4949
A page gets less than the screen: the sidebar and the agent panel squeeze it on wide screens too. Layouts which stack follow their container with Tailwind container queries (`@container` on the parent, `@4xl:` on children), not the viewport with `md:` or `lg:`. Such a grid gets `grid-cols-1` as its base, otherwise a wide chart or table stretches its single column past the page.
5050

51-
The header of the list page is a container named `header`. Below 896px (`@4xl/header`) components from `beforeActionButtons` take a full row under the title and the buttons, from 896px all share one row. Below 512px (`@lg/header`) Home and Filter keep only their icons. Such a component fills its row and takes a fixed width from `@4xl/header:`, like the fields of quick-filters. Bulk actions of checked rows take a row of their own above the table, so the buttons next to the title never move.
51+
The header of the list page is a container named `header`. Below 896px (`@4xl/header`) components from `beforeActionButtons` take a full row under the title and the buttons, from 896px all share one row. Below 512px (`@lg/header`) Home and Filter keep only their icons. Such a component fills its row and takes a fixed width from `@4xl/header:`, like the fields of quick-filters.
52+
53+
## Bulk actions bar
54+
55+
Checking rows never moves the table or the header and never changes the height of the table. Bulk actions of checked rows show up in a dark floating bar in place of the pagination: the pagination turns invisible and keeps its room, changing the page drops the checked rows anyway. The bar takes no room itself and sticks to the bottom of the screen over a long table. It holds the count, the actions as ghost buttons, Clear. All actions stay in sight, no sideways scroll: on a narrow screen they wrap between the count and Clear, left aligned, and the bar grows up over the last rows. The bar is an ink layer like a tooltip and takes the `Tooltip*` tokens, so it is light in the dark theme. A destructive action on it takes the `Danger` token of the opposite theme.
5256

5357
## Field tables
5458

‎adminforth/design/system.html‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,21 @@
438438
:where(#list) .dr>.dc:nth-child(9){flex-basis:104px}
439439
:where(#list) .dr>.dc:nth-child(10){flex-basis:120px}
440440
:where(#list) .dr>.dc:nth-child(11){flex-basis:136px}
441+
/* the bar takes the place of the invisible pagination: a zero-height sticky line under it, the negative margin cancels the gap of the column */
442+
:where(#list) .bulk-bar-wrap{position:sticky;bottom:16px;z-index:3;height:0;margin-top:-16px}
443+
:where(#list) .bulk-bar-row{position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:center;padding:0 16px}
444+
/* all actions stay in sight: on a narrow screen they wrap between the count and Clear, the dividers take their height */
445+
:where(#list) .bulk-bar{display:flex;align-items:center;gap:4px;max-width:100%;padding:4px 4px 4px 14px;border-radius:8px;background:var(--ink);color:var(--on-ink);box-shadow:var(--shadow-pop)}
446+
:where(#list) .bulk-bar .count{flex:none;margin-right:6px;font-size:13px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap}
447+
:where(#list) .bulk-bar .vr{flex:none;align-self:stretch;width:1px;margin:4px;background:color-mix(in oklab,var(--on-ink) 20%,transparent)}
448+
:where(#list) .bulk-bar .acts{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
449+
:where(#list) .bulk-bar .af-bulk-actions-clear-button{flex:none}
450+
:where(#list) .bulk-bar .btn,:where(#list) .bulk-bar .btn:hover{color:var(--on-ink)}
451+
:where(#list) .bulk-bar .btn:hover{background:color-mix(in oklab,var(--on-ink) 10%,transparent)}
452+
:where(#list) .bulk-bar .btn .i{color:inherit}
453+
/* the bar inverts with the theme, a destructive action takes the danger color of the opposite one */
454+
:where(#list) .bulk-bar .btn.is-danger{color:#FF9592}
455+
:where(#list) .dark .bulk-bar .btn.is-danger{color:#B42318}
441456

442457
:where(#create) a{color:inherit}
443458
:where(#create) a:hover{color:inherit}
@@ -1211,6 +1226,54 @@ <h1 class="t-title">Car - SQLite</h1>
12111226

12121227

12131228

1229+
</div>
1230+
</div>
1231+
<!-- the bottom of the list with checked rows: the bar of bulk actions takes the place of the pagination, which turns invisible and keeps its room -->
1232+
<div class="ds-frame" style="margin-top: 16px">
1233+
<div class="af" style="--accent: #16537E; padding: 24px 32px; display: flex; flex-direction: column; gap: 16px">
1234+
<div class="tbl-box">
1235+
<div class="tbl-scroll">
1236+
<div class="dt" role="table" aria-label="Cars">
1237+
<div class="dbody">
1238+
<div class="dr is-sel" role="row">
1239+
<div class="dc" role="cell"><input class="cb" type="checkbox" aria-label="Select row" checked></div>
1240+
<div class="dc" role="cell"><span class="mono t-muted">b71e0d93</span></div>
1241+
<div class="dc" role="cell"><a class="link-u" href="#" style="font-weight: 500">Borealis Aurora X</a></div>
1242+
<div class="dc r num" role="cell" style="color: var(--text)">$8,950</div>
1243+
<div class="dc" role="cell">Hybrid</div>
1244+
<div class="dc r num" role="cell">218 hp</div>
1245+
<div class="dc num" role="cell">2024</div>
1246+
<div class="dc" role="cell"><span class="badge badge-success"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Yes</span></div>
1247+
<div class="dc r num" role="cell">6,120 km</div>
1248+
<div class="dc t-muted" role="cell">Oct 14, 2026</div>
1249+
<div class="dc r" role="cell">
1250+
<span class="row-actions">
1251+
<a class="btn btn-ghost btn-icon btn-sm" href="#" aria-label="Show"><svg class="i af-show-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path><circle cx="12" cy="12" r="3"></circle></svg></a>
1252+
<a class="btn btn-ghost btn-icon btn-sm" href="#" aria-label="Edit"><svg class="i af-edit-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"></path><path d="m15 5 4 4"></path></svg></a>
1253+
<button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="Delete"><svg class="i af-delete-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg></button>
1254+
<button class="btn btn-ghost btn-icon btn-sm" type="button" aria-label="More"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
1255+
</span>
1256+
</div>
1257+
</div>
1258+
</div>
1259+
</div>
1260+
</div>
1261+
</div>
1262+
<div class="af-pagination-container" style="visibility: hidden; height: 28px" aria-hidden="true"></div>
1263+
<div class="bulk-bar-wrap af-bulk-actions-bar-container">
1264+
<div class="bulk-bar-row">
1265+
<div class="bulk-bar af-bulk-actions-bar" role="toolbar" aria-label="Bulk actions">
1266+
<span class="count af-bulk-actions-count">1 selected</span>
1267+
<span class="vr" aria-hidden="true"></span>
1268+
<div class="acts">
1269+
<button class="btn btn-ghost btn-sm af-bulk-action-button" type="button"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path></svg>Generate description</button>
1270+
<button class="btn btn-ghost btn-sm af-bulk-action-button is-danger" type="button"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>Delete</button>
1271+
</div>
1272+
<span class="vr" aria-hidden="true"></span>
1273+
<button class="btn btn-ghost btn-sm af-bulk-actions-clear-button" type="button"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>Clear</button>
1274+
</div>
1275+
</div>
1276+
</div>
12141277
</div>
12151278
</div>
12161279
</section>

‎adminforth/spa/src/components/BreadcrumbsWithButtons.vue‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@
99
<div v-if="$slots.filters" class="order-last basis-full flex flex-wrap items-center gap-2 @4xl/header:order-none @4xl/header:basis-auto @4xl/header:ms-auto">
1010
<slot name="filters"></slot>
1111
</div>
12-
<!-- actions for the checked rows get a row of their own above the table, so the buttons next to the title stay in place -->
13-
<div v-if="$slots.selection" class="order-last basis-full flex flex-wrap items-center gap-2">
14-
<slot name="selection"></slot>
15-
</div>
1612
<div class="flex items-center gap-2 flex-wrap">
1713
<slot></slot>
1814
</div>

‎adminforth/spa/src/components/ResourceListTable.vue‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,9 @@
273273
</div>
274274
<!-- pagination
275275
totalRows in v-if is used to not hide page input during loading when user puts cursor into it and edit directly (rows gets null there during edit)
276+
hidden pagination keeps its room, e.g. the bar of checked rows covers it
276277
-->
277-
<div class="af-pagination-container flex flex-row items-center mt-4 xs:flex-row xs:justify-between xs:items-center gap-3">
278+
<div class="af-pagination-container flex flex-row items-center mt-4 xs:flex-row xs:justify-between xs:items-center gap-3" :class="{ invisible: hidePagination }">
278279

279280
<!-- one hairline frame around 26px buttons split by lighter dividers, the current page is an inset field -->
280281
<div class="af-pagination-buttons-container af-button-shadow inline-flex overflow-hidden rounded-default border border-lightListTablePaginationBorder bg-lightListTablePaginationBackgoround divide-x rtl:divide-x-reverse divide-lightListTablePaginationBorder/60 dark:border-darkListTablePaginationBorder dark:bg-darkListTablePaginationBackgoround dark:divide-darkListTablePaginationBorder/60"
@@ -433,6 +434,7 @@ const props = withDefaults(defineProps<{
433434
checkboxes: any[],
434435
sort: any[],
435436
noRoundings?: boolean,
437+
hidePagination?: boolean,
436438
customActionsInjection?: any[],
437439
tableBodyStartInjection?: any[],
438440
containerHeight?: number,

‎adminforth/spa/src/views/ListView.vue‎

Lines changed: 75 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -32,65 +32,6 @@
3232
:clearCheckboxes="clearCheckboxes"
3333
/>
3434
</template>
35-
<template v-if="isResourceReady && checkboxes.length" #selection>
36-
<Button
37-
@click="()=>{checkboxes = []}"
38-
variant="secondary"
39-
iconOnly
40-
>
41-
<Tooltip>
42-
<IconBan class="w-4 h-4"/>
43-
<template #tooltip >
44-
Remove selection
45-
</template>
46-
</Tooltip>
47-
</Button>
48-
49-
<div
50-
v-for="(action,i) in coreStore.resource?.options?.bulkActions?.filter(a => !a.showInThreeDotsDropdown)"
51-
>
52-
<Button
53-
:key="action.id"
54-
@click="startBulkActionInner(action.id!)"
55-
class="h-[2.125rem] px-2.5"
56-
:class="action.buttonCustomCssClass || ''"
57-
variant="secondary"
58-
>
59-
<component
60-
v-if="action.icon && !bulkActionLoadingStates[action.id!]"
61-
:is="getIcon(action.icon)"
62-
class="w-4 h-4 transition duration-75"></component>
63-
<Spinner
64-
v-if="bulkActionLoadingStates[action.id!]"
65-
class="w-4 h-4"
66-
/>
67-
{{ `${action.label} (${checkboxes.length})` }}
68-
<div v-if="action.badge" class="inline-flex items-center h-[1.125rem] px-1.5 ml-1 rounded-full bg-purple-600 text-white text-[11px] font-semibold">
69-
{{ action.badge }}
70-
</div>
71-
</Button>
72-
</div>
73-
<div
74-
v-for="(action,i) in coreStore.resource?.options?.actions?.filter(a => a.showIn?.bulkButton)"
75-
>
76-
<Button
77-
:key="action.id"
78-
@click="startCustomBulkActionInner(action.id!)"
79-
class="h-[2.125rem] px-2.5"
80-
variant="secondary"
81-
>
82-
<component
83-
v-if="action.icon && !customActionLoadingStates[action.id!]"
84-
:is="getIcon(action.icon)"
85-
class="w-4 h-4 transition duration-75"></component>
86-
<Spinner
87-
v-if="customActionLoadingStates[action.id!]"
88-
class="w-4 h-4"
89-
/>
90-
{{ `${action.name} (${checkboxes.length})` }}
91-
</Button>
92-
</div>
93-
</template>
9435
<template v-if="isResourceReady">
9536
<LinkButton v-if="coreStore.resource?.options?.allowedActions?.create"
9637
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
@@ -183,8 +124,77 @@
183124
:item-height="52.5"
184125
:buffer-size="listBufferSize"
185126
:isVirtualScrollEnabled="isVirtualScrollEnabled"
127+
:hidePagination="checkboxes.length > 0"
186128
/>
187129

130+
<Transition
131+
enter-from-class="opacity-0 translate-y-2"
132+
enter-active-class="transition duration-150 ease-out"
133+
leave-active-class="transition duration-100 ease-in"
134+
leave-to-class="opacity-0 translate-y-2"
135+
>
136+
<!--
137+
the bar of checked rows takes the place of the pagination, which keeps its room: changing the page drops the checked rows anyway.
138+
So it takes no room itself and covers no rows, over a long table it sticks to the bottom of the screen.
139+
All actions stay in sight: on a narrow screen they wrap between the count and Clear, and the bar grows up over the last rows
140+
-->
141+
<div v-if="isResourceReady && checkboxes.length" class="af-bulk-actions-bar-container sticky bottom-4 z-30 h-0">
142+
<div class="absolute inset-x-0 bottom-0 flex justify-center px-4 pointer-events-none">
143+
<div
144+
role="toolbar"
145+
:aria-label="$t('Bulk actions')"
146+
class="af-bulk-actions-bar pointer-events-auto flex items-center gap-1 max-w-full p-1 ps-3.5 rounded-lg bg-lightTooltipBackground text-lightTooltipText shadow-popover dark:bg-darkTooltipBackground dark:text-darkTooltipText dark:shadow-darkPopover"
147+
>
148+
<span class="af-bulk-actions-count shrink-0 me-1.5 text-[13px] font-medium tabular-nums whitespace-nowrap">{{ $t('{count} selected', { count: checkboxes.length }) }}</span>
149+
<!-- dividers take the height of the wrapped actions -->
150+
<span class="shrink-0 self-stretch w-px my-1 mx-1 bg-lightTooltipText/20 dark:bg-darkTooltipText/20" aria-hidden="true"></span>
151+
<div class="flex flex-wrap items-center gap-1">
152+
<Button
153+
v-for="action in coreStore.resource?.options?.bulkActions?.filter(a => !a.showInThreeDotsDropdown)"
154+
:key="action.id"
155+
@click="startBulkActionInner(action.id!)"
156+
:loader="bulkActionLoadingStates[action.id!]"
157+
variant="ghost"
158+
size="sm"
159+
class="af-bulk-action-button"
160+
:class="[BULK_BAR_BUTTON_CLASSES, action.dangerous && BULK_BAR_DANGER_CLASSES, action.buttonCustomCssClass]"
161+
>
162+
<component v-if="action.icon && !bulkActionLoadingStates[action.id!]" :is="getIcon(action.icon)" class="w-4 h-4" />
163+
{{ action.label }}
164+
<span v-if="action.badge" class="inline-flex items-center h-[1.125rem] px-1.5 rounded-full text-[11px] font-semibold bg-lightPrimary text-lightPrimaryContrast dark:bg-darkPrimary dark:text-darkPrimaryContrast">
165+
{{ action.badge }}
166+
</span>
167+
</Button>
168+
<Button
169+
v-for="action in coreStore.resource?.options?.actions?.filter(a => a.showIn?.bulkButton)"
170+
:key="action.id"
171+
@click="startCustomBulkActionInner(action.id!)"
172+
:loader="customActionLoadingStates[action.id!]"
173+
variant="ghost"
174+
size="sm"
175+
class="af-bulk-action-button"
176+
:class="[BULK_BAR_BUTTON_CLASSES, action.bulkDangerous && BULK_BAR_DANGER_CLASSES]"
177+
>
178+
<component v-if="action.icon && !customActionLoadingStates[action.id!]" :is="getIcon(action.icon)" class="w-4 h-4" />
179+
{{ action.name }}
180+
</Button>
181+
</div>
182+
<span class="shrink-0 self-stretch w-px my-1 mx-1 bg-lightTooltipText/20 dark:bg-darkTooltipText/20" aria-hidden="true"></span>
183+
<Button
184+
@click="clearCheckboxes"
185+
variant="ghost"
186+
size="sm"
187+
class="af-bulk-actions-clear-button shrink-0"
188+
:class="BULK_BAR_BUTTON_CLASSES"
189+
>
190+
<IconX class="w-4 h-4" />
191+
{{ $t('Clear') }}
192+
</Button>
193+
</div>
194+
</div>
195+
</div>
196+
</Transition>
197+
188198
<component
189199
v-for="c in coreStore?.resourceOptions?.pageInjections?.list?.bottom as AdminForthComponentDeclaration[] || []"
190200
:is="getCustomComponent(formatComponent(c))"
@@ -207,12 +217,11 @@ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue';
207217
import { useRoute } from 'vue-router';
208218
import { getCustomComponent, getList, startBulkAction, lsKey } from '@/utils';
209219
import ThreeDotsMenu from '@/components/ThreeDotsMenu.vue';
210-
import { Tooltip, Spinner, Button, LinkButton } from '@/afcl'
220+
import { Button, LinkButton } from '@/afcl'
211221
import type { AdminForthComponentDeclaration, AdminForthComponentDeclarationFull, AdminForthFilterOperators, AdminForthResourceColumnCommon } from '@/types/Common';
212222
import { useI18n } from 'vue-i18n';
213223
214224
import {
215-
IconBan,
216225
IconListFilter,
217226
IconPlus,
218227
IconX
@@ -233,6 +242,11 @@ const route = useRoute();
233242
const isResourceReady = computed(() => coreStore.resource?.resourceId === route.params.resourceId);
234243
const beforeActionButtons = computed(() => (coreStore.resourceOptions?.pageInjections?.list?.beforeActionButtons || []) as AdminForthComponentDeclaration[]);
235244
245+
// ghost buttons on the bar of checked rows take its text color, the bar is dark in the light theme and light in the dark one
246+
const BULK_BAR_BUTTON_CLASSES = 'text-lightTooltipText hover:bg-lightTooltipText/10 hover:text-lightTooltipText dark:text-darkTooltipText dark:hover:bg-darkTooltipText/10 dark:hover:text-darkTooltipText';
247+
// so a destructive action on it takes the danger color of the opposite theme
248+
const BULK_BAR_DANGER_CLASSES = 'text-darkDanger hover:text-darkDanger dark:text-lightDanger dark:hover:text-lightDanger';
249+
236250
const page = ref(1);
237251
const columnsMinMax = ref({});
238252
const sort = ref();

‎documentation/docs/tutorial/03-Customization/08-pageInjections.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,7 @@ The header lays out by its own width, not by the width of the screen, because th
805805
- below 896px the injected components take a full row under the title and the buttons;
806806
- from 896px the title, the injected components and the buttons share one row, the components stand to the left of the buttons.
807807
808-
Buttons of bulk actions show up in a row of their own above the table when rows are checked, so they never push the components or the buttons around.
808+
Buttons of bulk actions show up in a floating bar in place of the pagination when rows are checked, so they never push the components, the buttons or the table around.
809809
810810
The header is a Tailwind [size container](https://github.com/tailwindlabs/tailwindcss-container-queries) named `header`, so a component can follow it with `@4xl/header:` variants. For example, a search input fills its row on a narrow header and gets a fixed width in the single row of a wide one:
811811

0 commit comments

Comments
 (0)