Skip to content

Commit 1b959f1

Browse files
committed
feature EasyCorp#7673 Improve the Twig component to create action menus (javiereguiluz)
This PR was squashed before being merged into the 5.x branch. Discussion ---------- Improve the Twig component to create action menus This updates the existing `<twig:ea:ActionMenu>` component: * It adds `<twig:ea:ActionMenu:RadioList>` and `<twig:ea:ActionMenu:CheckboxList>` subcomponents so you can group a series of menu items in a mutually-exclusive or mutually-additive sublist. E.g. we now use a `RadioList` to show the local and appearance menus, because you can only pick one of those values. * It updates the design a bit to modernize it and make it look more like current design systems. A few examples of the new design and features: <img width="2398" height="1906" alt="action-menu-examples" src="https://github.com/user-attachments/assets/ec3b90dd-8208-4379-9f44-455b43c7ca1f" /> Commits ------- 910cb45 Improve the Twig component to create action menus
2 parents 0876c44 + 910cb45 commit 1b959f1

17 files changed

Lines changed: 301 additions & 72 deletions
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/* ActionMenu (dropdown)
2+
/* ------------------------------------------------------------------------- */
3+
/* Open/close animation, selectable (radio/checkbox) items and submenu chevron,
4+
modeled on the shadcn/ui Dropdown Menu. */
5+
6+
.dropdown-menu {
7+
--ea-action-menu-slide: 8px;
8+
}
9+
10+
/* ENTER: triggered when Bootstrap adds the `.show` class. We animate the
11+
individual `scale`/`translate` properties (not `transform`) so they compose
12+
on top of Popper's inline `transform: translate()` positioning instead of
13+
clobbering it. `transform-origin` is anchored to the corner nearest the
14+
toggle, mimicking shadcn's --radix-*-transform-origin. */
15+
.dropdown-menu.show {
16+
/* `ease` (the CSS default) is exactly what shadcn/tw-animate-css uses. A strong
17+
decelerate curve (e.g. --ea-ease-out) has a long, slow tail that reads as sluggish
18+
at this short duration, so we deliberately match shadcn's `ease` here */
19+
animation: ea-action-menu-enter var(--ea-duration-default) ease;
20+
}
21+
@keyframes ea-action-menu-enter {
22+
from {
23+
opacity: 0;
24+
scale: 0.95;
25+
translate: var(--ea-enter-x, 0) var(--ea-enter-y, 0);
26+
}
27+
to {
28+
opacity: 1;
29+
scale: 1;
30+
translate: 0 0;
31+
}
32+
}
33+
.dropdown-menu[data-popper-placement^="bottom"] {
34+
transform-origin: top center;
35+
--ea-enter-y: calc(-1 * var(--ea-action-menu-slide));
36+
}
37+
.dropdown-menu[data-popper-placement^="top"] {
38+
transform-origin: bottom center;
39+
--ea-enter-y: var(--ea-action-menu-slide);
40+
}
41+
.dropdown-menu[data-popper-placement^="left"] {
42+
transform-origin: right center;
43+
--ea-enter-x: var(--ea-action-menu-slide);
44+
}
45+
.dropdown-menu[data-popper-placement^="right"] {
46+
transform-origin: left center;
47+
--ea-enter-x: calc(-1 * var(--ea-action-menu-slide));
48+
}
49+
.dropdown-menu[data-popper-placement$="-end"] {
50+
transform-origin: top right;
51+
}
52+
.dropdown-menu[data-popper-placement$="-start"] {
53+
transform-origin: top left;
54+
}
55+
56+
/* EXIT: Bootstrap removes `.show` and destroys Popper synchronously, so a
57+
CSS-only exit can't play. page-layout.js adds `.ea-dropdown-hiding` while
58+
`.show` (and Popper positioning) is still in place, plays this, then lets
59+
Bootstrap finish hiding */
60+
.dropdown-menu.ea-dropdown-hiding {
61+
animation: ea-action-menu-exit var(--ea-duration-fast) var(--ea-ease-in) forwards;
62+
pointer-events: none;
63+
}
64+
@keyframes ea-action-menu-exit {
65+
from {
66+
opacity: 1;
67+
scale: 1;
68+
}
69+
to {
70+
opacity: 0;
71+
scale: var(--ea-action-menu-zoom-from);
72+
}
73+
}
74+
75+
@media (prefers-reduced-motion: reduce) {
76+
.dropdown-menu.show,
77+
.dropdown-menu.ea-dropdown-hiding {
78+
animation: none;
79+
}
80+
}
81+
82+
.dropdown-menu .dropdown-selectable-group {
83+
list-style: none;
84+
margin: 0;
85+
padding: 0;
86+
}
87+
.dropdown-menu .dropdown-item-trailing {
88+
margin-inline-start: auto;
89+
display: inline-flex;
90+
align-items: center;
91+
}
92+
.dropdown-menu .dropdown-item-check {
93+
color: var(--color-primary);
94+
inline-size: 16px;
95+
block-size: 16px;
96+
opacity: 0;
97+
transition: opacity var(--ea-duration-fast) var(--ea-ease-out);
98+
}
99+
.dropdown-menu .dropdown-item[aria-checked="true"] .dropdown-item-check,
100+
.dropdown-menu .dropdown-item-selectable.active .dropdown-item-check {
101+
opacity: 1;
102+
}
103+
104+
.dropdown-menu .dropdown-item-selectable.active,
105+
.dropdown-menu .dropdown-item-selectable[aria-checked="true"] {
106+
background: var(--dropdown-settings-active-item-bg);
107+
box-shadow: none;
108+
color: var(--dropdown-link-color);
109+
}
110+
.dropdown-menu .dropdown-item-selectable.active .icon,
111+
.dropdown-menu .dropdown-item-selectable[aria-checked="true"] .icon {
112+
color: var(--dropdown-icon-color);
113+
}
114+
.dropdown-menu .dropdown-item-selectable.active .dropdown-item-check {
115+
color: var(--color-primary);
116+
}
117+
118+
.dropdown-submenu .dropdown-toggle::after,
119+
.dropdown-submenu .dropdown-toggle::before {
120+
display: none; /* hide Bootstrap's native caret; we render a chevron icon instead */
121+
}
122+
.dropdown-menu .dropdown-submenu-chevron {
123+
margin-inline-start: auto;
124+
color: var(--text-muted);
125+
inline-size: 16px;
126+
block-size: 16px;
127+
}
128+
/* the chevron follows the side the submenu actually opens to: right by default
129+
(chevron-right, no flip), flipped to point left when the submenu opens to the
130+
left (Bootstrap's .dropstart, e.g. datagrid row actions) */
131+
.dropstart > .dropdown-submenu-chevron,
132+
.dropstart > * .dropdown-submenu-chevron {
133+
scale: -1 1; /* point left */
134+
order: -1; /* and sit on the leading (left) edge, the side it opens to */
135+
margin-inline-start: 0;
136+
}

assets/css/easyadmin-theme/base.css

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -707,24 +707,28 @@ a.user-menu-wrapper .user-details:hover {
707707

708708
.dropdown-menu {
709709
--dropdown-padding: 4px;
710+
--dropdown-radius: var(--border-radius-lg); /* 8px */
711+
--dropdown-item-radius: var(--border-radius-lg); /* 8px */
710712

711713
background-color: var(--dropdown-bg);
712714
border-color: var(--dropdown-border-color);
715+
border-radius: var(--dropdown-radius);
713716
box-shadow: var(--shadow-xl);
714717
color: var(--dropdown-color);
718+
min-inline-size: 8rem;
715719
max-inline-size: 240px;
716-
padding: 5px;
720+
padding: var(--dropdown-padding);
717721
}
718722
.dropdown-menu.dropdown-has-submenus {
719723
padding-inline-start: 25px;
720724
}
721725
.dropdown-menu li {
722-
border-radius: var(--border-radius);
726+
border-radius: var(--dropdown-item-radius);
723727
}
724728
.dropdown-menu a,
725729
.dropdown-menu a:hover,
726730
.dropdown-menu a:active {
727-
border-radius: var(--border-radius);
731+
border-radius: var(--dropdown-item-radius);
728732
color: var(--dropdown-link-color);
729733
}
730734
.dropdown-menu a:hover {
@@ -733,25 +737,41 @@ a.user-menu-wrapper .user-details:hover {
733737
.dropdown-menu i,
734738
.dropdown-menu .icon {
735739
color: var(--dropdown-icon-color);
736-
margin: 0 8px 0 0;
737-
font-size: 15px;
740+
margin: 0;
741+
/* 14px (not 16px): FontAwesome's filled glyphs read heavier than icon sets with thin SVG strokes,
742+
so a slightly smaller size matches the visual weight. */
743+
font-size: 14px;
738744
}
739745
.dropdown-menu .icon i {
740746
margin: 0;
747+
line-height: 1;
741748
}
742749
.dropdown-menu .icon {
743750
display: inline-flex;
751+
align-items: center;
744752
justify-content: center;
753+
/* fixed-width icon column following FontAwesome's .fa-fw (1.25em): every FA glyph is
754+
designed to fit within 1.25em, so icons render full-size, stay centered, never get
755+
clipped, and labels line up. SVG icons are capped at 1em and centered in the same
756+
column. */
757+
inline-size: 1.25em;
758+
block-size: 16px;
759+
flex-shrink: 0;
760+
/* reset the item's 20px line-height so glyphs aren't inflated vertically */
761+
line-height: 1;
745762
}
746763

747764
.dropdown-menu .dropdown-item,
748765
.dropdown-menu .dropdown-header {
749766
align-items: center;
750767
display: flex;
751-
block-size: 28px;
768+
gap: 6px;
769+
min-block-size: 28px;
752770
white-space: nowrap;
753771
overflow: hidden;
754-
padding: 0 12px 0 6px;
772+
padding: 4px 6px;
773+
font-size: var(--font-size-base);
774+
line-height: 1.25rem; /* 28px */
755775
text-overflow: ellipsis;
756776
}
757777
.dropdown-menu .dropdown-divider {

assets/css/easyadmin-theme/datagrids.css

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,6 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
280280
position: relative;
281281
}
282282

283-
.datagrid .dropdown-actions .dropstart .dropdown-toggle:before {
284-
margin-inline-start: -20px;
285-
position: absolute;
286-
}
287-
288283
/* Position nested dropdown menu to the left */
289284
.datagrid .dropdown-actions .dropdown-menu .dropstart > .dropdown-menu {
290285
inset-block-start: 0;
@@ -329,15 +324,6 @@ table.datagrid:not(.datagrid-empty) tr:not(.empty-row) td.actions.actions-as-dro
329324
display: none;
330325
}
331326

332-
.datagrid .dropdown-actions .dropdown-menu .dropstart .dropdown-toggle-split .dropdown-toggle-marker {
333-
/* this is a chevron-like icon made out of text with CSS border tricks */
334-
border-block-end: .3em solid transparent;
335-
border-inline-end: .3em solid;
336-
border-block-start: .3em solid transparent;
337-
content: "";
338-
display: inline-block;
339-
}
340-
341327
.datagrid .ea-lightbox-thumbnail img {
342328
background: var(--white);
343329
border: 1px solid transparent;

assets/css/easyadmin-theme/theme.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@import "./variables-theme.css";
33

44
@import "./base.css";
5+
@import "./action-menu.css";
56
@import "./menu.css";
67
@import "./datagrids.css";
78
@import "./detail-page.css";

assets/js/page-color-scheme.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ class ColorSchemeHandler {
2222
const colorSchemeSelectors = document.querySelectorAll('.dropdown-settings');
2323
const currentScheme = localStorage.getItem(this.#colorSchemeLocalStorageKey) || 'auto';
2424

25+
// toggle the selected state via aria-checked (this drives the trailing checkmark
26+
// shown by CSS); the legacy `active` class is kept in sync for one release
27+
const setChecked = (el, isChecked) => {
28+
el.setAttribute('aria-checked', isChecked ? 'true' : 'false');
29+
el.classList.toggle('active', isChecked);
30+
};
31+
2532
colorSchemeSelectors.forEach((colorSchemeSelector) => {
2633
const selectorOptions = colorSchemeSelector.querySelectorAll(
2734
'a.dropdown-appearance-item[data-ea-color-scheme]'
@@ -30,10 +37,8 @@ class ColorSchemeHandler {
3037
`a.dropdown-appearance-item[data-ea-color-scheme="${currentScheme}"]`
3138
);
3239

33-
selectorOptions.forEach((selector) => {
34-
selector.classList.remove('active');
35-
});
36-
selectorActiveOption.classList.add('active');
40+
selectorOptions.forEach((selector) => setChecked(selector, false));
41+
setChecked(selectorActiveOption, true);
3742

3843
selectorOptions.forEach((selector) => {
3944
selector.addEventListener('click', () => {
@@ -46,12 +51,8 @@ class ColorSchemeHandler {
4651
const allSelectorActiveOptions = document.querySelectorAll(
4752
`a.dropdown-appearance-item[data-ea-color-scheme="${selectedColorScheme}"]`
4853
);
49-
allSelectorOptions.forEach((selectorOption) => {
50-
selectorOption.classList.remove('active');
51-
});
52-
allSelectorActiveOptions.forEach((selectorOption) => {
53-
selectorOption.classList.add('active');
54-
});
54+
allSelectorOptions.forEach((selectorOption) => setChecked(selectorOption, false));
55+
allSelectorActiveOptions.forEach((selectorOption) => setChecked(selectorOption, true));
5556
});
5657
});
5758
});

assets/js/page-layout.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,51 @@ document.body.classList.add(
22
`ea-content-width-${localStorage.getItem('ea/content/width') || document.body.dataset.eaContentWidth}`,
33
`ea-sidebar-width-${localStorage.getItem('ea/sidebar/width') || document.body.dataset.eaSidebarWidth}`
44
);
5+
6+
// ActionMenu (Bootstrap dropdown) close animation.
7+
// The enter animation is pure CSS (.dropdown-menu.show), but Bootstrap removes
8+
// `.show` and destroys Popper synchronously when hiding, so a CSS-only exit can't
9+
// play. We intercept `hide.bs.dropdown` (a native bubbling CustomEvent, so no
10+
// Bootstrap import is needed here), play the CSS exit while `.show` and Popper
11+
// positioning are still in place, then let Bootstrap finish hiding.
12+
(() => {
13+
const EXIT_CLASS = 'ea-dropdown-hiding';
14+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
15+
16+
const menuFor = (toggle) => {
17+
const wrapper = toggle.closest('.dropdown, .dropstart, .dropend, .dropup');
18+
19+
return wrapper?.querySelector(':scope > .dropdown-overlay > .dropdown-menu, :scope > .dropdown-menu');
20+
};
21+
22+
document.addEventListener('hide.bs.dropdown', (event) => {
23+
if (reduceMotion || undefined === window.bootstrap) {
24+
return; // hide instantly
25+
}
26+
27+
const menu = menuFor(event.target);
28+
if (!menu) {
29+
return;
30+
}
31+
32+
// The programmatic hide() re-fires this event; let it pass through.
33+
if ('1' === menu.dataset.eaAllowHide) {
34+
delete menu.dataset.eaAllowHide;
35+
menu.classList.remove(EXIT_CLASS);
36+
37+
return;
38+
}
39+
40+
event.preventDefault();
41+
menu.classList.add(EXIT_CLASS);
42+
43+
const finish = () => {
44+
clearTimeout(fallback);
45+
menu.removeEventListener('animationend', finish);
46+
menu.dataset.eaAllowHide = '1';
47+
window.bootstrap.Dropdown.getOrCreateInstance(event.target).hide();
48+
};
49+
const fallback = setTimeout(finish, 250); // safety net if animationend never fires
50+
menu.addEventListener('animationend', finish, { once: true });
51+
});
52+
})();
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/entrypoints.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"entrypoints": {
33
"app": {
44
"css": [
5-
"/app.cec96de7.css"
5+
"/app.0fb8b8bb.css"
66
],
77
"js": [
88
"/app.b016c66e.js"
@@ -15,12 +15,12 @@
1515
},
1616
"page-layout": {
1717
"js": [
18-
"/page-layout.6e9fe55d.js"
18+
"/page-layout.696f1816.js"
1919
]
2020
},
2121
"page-color-scheme": {
2222
"js": [
23-
"/page-color-scheme.75224563.js"
23+
"/page-color-scheme.e9aaf2f0.js"
2424
]
2525
},
2626
"field-boolean": {

public/manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
2-
"app.css": "app.cec96de7.css",
2+
"app.css": "app.0fb8b8bb.css",
33
"app.js": "app.b016c66e.js",
44
"form.js": "form.bd28918c.js",
5-
"page-layout.js": "page-layout.6e9fe55d.js",
6-
"page-color-scheme.js": "page-color-scheme.75224563.js",
5+
"page-layout.js": "page-layout.696f1816.js",
6+
"page-color-scheme.js": "page-color-scheme.e9aaf2f0.js",
77
"field-boolean.js": "field-boolean.6adeff4e.js",
88
"field-code-editor.css": "field-code-editor.cdcf15eb.css",
99
"field-code-editor.js": "field-code-editor.a0cd3c60.js",

0 commit comments

Comments
 (0)