Skip to content

Commit 090541f

Browse files
committed
feat: list header follows its own width: filters from beforeActionButtons take a full row under the title and buttons on narrow screens and share one row from 896px, bulk actions of checked rows get a row of their own AdminForth/2005
1 parent 8b26578 commit 090541f

6 files changed

Lines changed: 106 additions & 56 deletions

File tree

‎adminforth/design/system-concepts.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ A disabled control gets the native `disabled` attribute (`aria-disabled` on a li
3333

3434
Below `sm` (640px) a Dialog or Modal docks to the bottom edge as a sheet: full width, 16px top corners, actions stacked full width with the last (main) one on top. Width classes an app passes (`class="w-96"`) apply from `sm` up. The panel and footer classes live in `afcl/dialogStyles.ts`, so an overlay built on `Modal` gets the sheet without extra code.
3535

36+
## Container width, not viewport width
37+
38+
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:`.
39+
40+
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.
41+
3642
## Lucide icons
3743

3844
- Use Lucide: `@iconify-prerendered/vue-lucide` in components (`IconPencil`, `IconTrash2`), `lucide:<name>` in config (`icon: 'lucide:download'`).

‎adminforth/design/system.html‎

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,6 @@
169169
.seg > button.is-on { background: var(--surface-3); color: var(--text); }
170170
.seg > button .i { color: var(--muted); }
171171
.seg > button.is-on .i { color: var(--text); }
172-
.seg-pill { display: inline-flex; padding: 2px; gap: 2px; border-radius: 8px; background: var(--surface-3); }
173-
.seg-pill > button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 500; cursor: pointer; }
174-
.seg-pill > button.is-on { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border), 0 1px 2px rgba(24, 24, 27, .06); }
175172

176173
/* links */
177174
.link { color: var(--accent-fg); text-decoration: none; font-weight: 500; }
@@ -251,6 +248,25 @@
251248
.badge-muted { background: var(--surface-2); border-color: transparent; color: var(--muted); }
252249
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
253250

251+
/* list page header: a container, so it follows its own width and not the viewport (the sidebar and the agent panel squeeze it too).
252+
Narrow: title and buttons in a row, filters in a full row under them. From 512px: Home and Filter labels. From 896px: one row. */
253+
.lh { container: header / inline-size; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-height: 36px; }
254+
.lh-title { flex: 1 1 0; min-width: 96px; display: flex; align-items: center; gap: 8px; }
255+
.lh-title .t-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
256+
.lh-filters { order: 1; flex: 1 1 100%; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 8px; }
257+
.lh-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
258+
.lh-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
259+
.lh-filter { padding: 0 8px; }
260+
@container header (min-width: 512px) {
261+
.lh-label { position: static; width: auto; height: auto; overflow: visible; clip: auto; }
262+
.lh-filter { padding: 0 12px; }
263+
}
264+
@container header (min-width: 896px) {
265+
.lh-filters { order: 0; flex: 0 1 auto; display: flex; flex-wrap: wrap; margin-left: auto; }
266+
.lh-filters .select { width: 192px; }
267+
.lh-filters label.input { width: 256px; }
268+
}
269+
254270
/* cards & tables */
255271
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
256272
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
@@ -779,50 +795,38 @@ <h2 class="ds-board-title"><span>Screens</span>List view</h2>
779795
</header>
780796

781797
<main class="af-content-wrapper" style="flex: 1; padding: 24px 32px 40px; display: flex; flex-direction: column; gap: 16px; min-width: 0">
782-
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px">
783-
<nav aria-label="Breadcrumb" style="display: flex; align-items: center; gap: 8px">
798+
<!-- the header follows its own width: filters take a full row under the title and the buttons, from 896px all share one row -->
799+
<div class="lh">
800+
<nav class="lh-title" aria-label="Breadcrumb">
784801
<a class="link-muted t-sm" href="#" style="display: inline-flex; align-items: center; gap: 6px">
785802
<svg class="i i-sm" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
786-
Home
803+
<span class="lh-label">Home</span>
787804
</a>
788805
<span style="color: var(--faint)">/</span>
789806
<h1 class="t-title">Car - SQLite</h1>
790807
</nav>
791-
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px">
792-
808+
<div class="lh-filters">
809+
<button class="input select" type="button" aria-haspopup="listbox" style="font: inherit; font-size: 14px; text-align: left"><span class="ph">Price</span><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></button>
810+
<label class="input">
811+
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>
812+
<input type="text" placeholder="Model" aria-label="Model">
813+
</label>
814+
</div>
815+
<div class="lh-actions">
793816
<a class="btn btn-primary af-create-button" href="#create">
794817
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
795818
Create
796819
</a>
797-
<button class="btn btn-secondary af-filter-button" type="button" >
820+
<button class="btn btn-secondary af-filter-button lh-filter" type="button" >
798821
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M7 12h10"></path><path d="M10 18h4"></path></svg>
799-
Filter
800-
822+
<span class="lh-label">Filter</span>
801823
</button>
802824
<button class="btn btn-secondary btn-icon" type="button" aria-label="More actions">
803825
<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>
804826
</button>
805827
</div>
806828
</div>
807829

808-
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
809-
<label class="input" style="width: 300px; max-width: 100%">
810-
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>
811-
<input type="text" placeholder="Search by model" aria-label="Model">
812-
</label>
813-
<div class="seg-pill" role="group" aria-label="Price">
814-
815-
<button class="is-on" type="button" >Any price</button>
816-
817-
<button class="" type="button" >Budget-Friendly</button>
818-
819-
<button class="" type="button" >Mid-Range</button>
820-
821-
<button class="" type="button" >Premium</button>
822-
823-
</div>
824-
</div>
825-
826830
<div class="tbl-box">
827831
<div class="tbl-scroll">
828832
<div class="dt" role="table" aria-label="Cars">

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
<template>
22
<nav class="flex items-center min-h-[2.25rem]" aria-label="Breadcrumb">
3-
<ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse flex-wrap">
4-
<li class="inline-flex items-center">
3+
<ol class="flex min-w-0 items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
4+
<li class="inline-flex shrink-0 items-center">
55
<RouterLink :to="{name: 'home'}" class="inline-flex items-center text-sm text-lightBreadcrumbsHomepageText hover:text-lightBreadcrumbsHomepageTextHover dark:text-darkBreadcrumbsHomepageText dark:hover:text-darkBreadcrumbsHomepageTextHover">
6-
<IconHouse class="w-3.5 h-3.5 me-1.5" aria-hidden="true" />
7-
{{ $t('Home') }}
6+
<IconHouse class="w-3.5 h-3.5 @lg/header:me-1.5" aria-hidden="true" />
7+
<!-- a narrow header keeps only the house, the label stays for screen readers -->
8+
<span class="sr-only @lg/header:not-sr-only">{{ $t('Home') }}</span>
89
</RouterLink>
910
</li>
10-
<li>
11+
<li class="min-w-0">
1112
<div class="flex items-center">
12-
<IconChevronRight class="rtl:rotate-180 w-3.5 h-3.5 text-lightBreadcrumbsArrowIcon dark:text-darkBreadcrumbsArrowIcon mx-0.5" aria-hidden="true" />
13-
<RouterLink :to="{name: 'resource-list', params: { resourceId: $route.params.resourceId }}" class="md:ms-1"
13+
<IconChevronRight class="shrink-0 rtl:rotate-180 w-3.5 h-3.5 text-lightBreadcrumbsArrowIcon dark:text-darkBreadcrumbsArrowIcon mx-0.5" aria-hidden="true" />
14+
<RouterLink :to="{name: 'resource-list', params: { resourceId: $route.params.resourceId }}" class="md:ms-1 truncate"
1415
:class="$route.params.primaryKey
1516
? 'text-sm text-lightBreadcrumbsHomepageText hover:text-lightBreadcrumbsHomepageTextHover dark:text-darkBreadcrumbsHomepageText dark:hover:text-darkBreadcrumbsHomepageTextHover'
1617
: 'text-xl leading-7 font-semibold tracking-tight text-lightBreadcrumbsText dark:text-darkBreadcrumbsText'">{{ coreStore.resourceById[$route.params.resourceId]?.label }}</RouterLink>
1718
</div>
1819
</li>
1920

20-
<li v-if="$route.params.primaryKey">
21+
<li v-if="$route.params.primaryKey" class="min-w-0">
2122
<div class="flex items-center">
22-
<IconChevronRight class="rtl:rotate-180 w-3.5 h-3.5 text-lightBreadcrumbsArrowIcon dark:text-darkBreadcrumbsArrowIcon mx-0.5" aria-hidden="true" />
23+
<IconChevronRight class="shrink-0 rtl:rotate-180 w-3.5 h-3.5 text-lightBreadcrumbsArrowIcon dark:text-darkBreadcrumbsArrowIcon mx-0.5" aria-hidden="true" />
2324
<span class="ms-1 text-xl leading-7 font-semibold tracking-tight text-lightBreadcrumbsText md:ms-1 dark:text-darkBreadcrumbsText
2425
max-w-80 truncate">
2526
{{ $route.name === 'resource-edit' ? $t('Edit') : $t('Show') }} {{ coreStore.record?._label }}</span>
Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
<template>
22
<div>
3-
<div class="flex items-center justify-between mb-4 flex-wrap gap-y-2 gap-2">
4-
<Breadcrumbs />
5-
<div class="flex items-center gap-2 flex-wrap gap-y-1">
6-
<slot></slot>
3+
<!-- the header follows its own width, not the viewport, because the sidebar and the agent panel squeeze it too -->
4+
<div class="@container/header mb-4">
5+
<div class="flex flex-wrap items-center gap-3">
6+
<!-- a record title of show and edit takes the room it needs and the buttons go under it, a resource title gives way to the buttons -->
7+
<Breadcrumbs :class="$route.params.primaryKey ? 'min-w-0 flex-auto' : 'min-w-24 flex-1'" />
8+
<!-- filters take a full row under the title and the buttons, a wide header keeps all in one row -->
9+
<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">
10+
<slot name="filters"></slot>
11+
</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>
16+
<div class="flex items-center gap-2 flex-wrap">
17+
<slot></slot>
18+
</div>
719
</div>
820
</div>
921
<div v-if="coreStore.resourceColumnsError" >
@@ -13,12 +25,12 @@
1325

1426
</template>
1527

16-
<script setup>
17-
import Breadcrumbs from '@/components/Breadcrumbs.vue';
28+
<script setup lang="ts">
29+
import Breadcrumbs from '@/components/Breadcrumbs.vue';
1830
import PageNotFound from '@/views/PageNotFound.vue';
1931
2032
import { useCoreStore } from '@/stores/core';
2133
2234
const coreStore = useCoreStore();
2335
24-
</script>
36+
</script>

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,20 @@
2121
/>
2222

2323
<BreadcrumbsWithButtons>
24-
<template v-if="isResourceReady">
24+
<template v-if="isResourceReady && beforeActionButtons.length" #filters>
2525
<component
26-
v-for="c in coreStore?.resourceOptions?.pageInjections?.list?.beforeActionButtons as AdminForthComponentDeclaration[] || []"
26+
v-for="c in beforeActionButtons"
2727
:is="getCustomComponent(formatComponent(c))"
2828
:meta="(c as AdminForthComponentDeclarationFull).meta"
2929
:resource="coreStore.resource"
3030
:adminUser="coreStore.adminUser"
3131
:checkboxes="checkboxes"
3232
:clearCheckboxes="clearCheckboxes"
3333
/>
34+
</template>
35+
<template v-if="isResourceReady && checkboxes.length" #selection>
3436
<Button
3537
@click="()=>{checkboxes = []}"
36-
v-if="checkboxes.length"
3738
data-tooltip-target="tooltip-remove-all"
3839
variant="secondary"
3940
iconOnly
@@ -47,11 +48,9 @@
4748
</Button>
4849

4950
<div
50-
v-if="checkboxes.length"
51-
v-for="(action,i) in coreStore.resource?.options?.bulkActions"
51+
v-for="(action,i) in coreStore.resource?.options?.bulkActions?.filter(a => !a.showInThreeDotsDropdown)"
5252
>
5353
<Button
54-
v-if="!action.showInThreeDotsDropdown"
5554
:key="action.id"
5655
@click="startBulkActionInner(action.id!)"
5756
class="h-[2.125rem] px-2.5"
@@ -73,7 +72,6 @@
7372
</Button>
7473
</div>
7574
<div
76-
v-if="checkboxes.length"
7775
v-for="(action,i) in coreStore.resource?.options?.actions?.filter(a => a.showIn?.bulkButton)"
7876
>
7977
<Button
@@ -93,7 +91,8 @@
9391
{{ `${action.name} (${checkboxes.length})` }}
9492
</Button>
9593
</div>
96-
94+
</template>
95+
<template v-if="isResourceReady">
9796
<LinkButton v-if="coreStore.resource?.options?.allowedActions?.create"
9897
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
9998
class="af-create-button h-[2.125rem] px-2.5"
@@ -103,13 +102,14 @@
103102
</LinkButton>
104103

105104
<Button
106-
class="af-filter-button h-[2.125rem] px-3 py-3"
105+
class="af-filter-button h-[2.125rem] px-2 py-3 @lg/header:px-3"
107106
@click="()=>{filtersShow = !filtersShow}"
108107
v-if="coreStore.resource?.options?.allowedActions?.filter"
109108
variant="secondary"
110109
>
111110
<IconListFilter class="w-4 h-4"/>
112-
<span>{{ $t('Filter') }}</span>
111+
<!-- a narrow header keeps only the icon, the label stays for screen readers -->
112+
<span class="sr-only @lg/header:not-sr-only">{{ $t('Filter') }}</span>
113113
<div v-if="filtersStore.visibleFiltersCount" class="flex items-center gap-1 ms-1">
114114
<span class="inline-flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 rounded-full text-[11px] font-semibold tabular-nums bg-lightPrimary text-lightPrimaryContrast dark:bg-darkPrimary dark:text-darkPrimaryContrast">
115115
{{ filtersStore.visibleFiltersCount }}
@@ -232,6 +232,7 @@ const route = useRoute();
232232
233233
// previous resource stays in the store until the new one is applied, its injections must not render here
234234
const isResourceReady = computed(() => coreStore.resource?.resourceId === route.params.resourceId);
235+
const beforeActionButtons = computed(() => (coreStore.resourceOptions?.pageInjections?.list?.beforeActionButtons || []) as AdminForthComponentDeclaration[]);
235236
236237
const page = ref(1);
237238
const columnsMinMax = ref({});

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ Notes and tips:
758758
759759
## List table beforeActionButtons
760760
761-
`beforeActionButtons` allows injecting one or more compact components into the header bar of the list page, directly to the left of the default action buttons (`Create`, `Filter`, bulk actions, three‑dots menu). Use it for small inputs (quick search, toggle, status chip) rather than large panels.
761+
`beforeActionButtons` allows injecting one or more compact components into the header bar of the list page, next to the default action buttons (`Create`, `Filter`, three‑dots menu). Use it for small inputs (quick search, toggle, status chip) rather than large panels.
762762
763763
![alt text](<Group 5.png>)
764764
@@ -798,6 +798,32 @@ beforeActionButtons: [
798798
799799
> ☝️ Keep these components visually light; wide or tall content should use `afterBreadcrumbs` or `bottom` instead.
800800
801+
### Narrow screens
802+
803+
The header lays out by its own width, not by the width of the screen, because the sidebar and the agent panel take space too:
804+
805+
- below 896px the injected components take a full row under the title and the buttons;
806+
- from 896px the title, the injected components and the buttons share one row, the components stand to the left of the buttons.
807+
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.
809+
810+
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:
811+
812+
```html title="./custom/UniversalQuickSearch.vue"
813+
<template>
814+
<div class="w-full @4xl/header:w-64">
815+
<Input v-model="term" fullWidth :placeholder="$t('Search')" />
816+
</div>
817+
</template>
818+
819+
<script setup lang="ts">
820+
import { ref } from 'vue';
821+
import { Input } from '@/afcl';
822+
823+
const term = ref('');
824+
</script>
825+
```
826+
801827
## List table custom
802828
803829
## Global Injections

0 commit comments

Comments
 (0)