0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-22 11:07:57 +01:00

Add new dropdown-button component

This commit is contained in:
Thibaud Colas 2023-06-23 14:22:07 +01:00
parent 89aa153168
commit 91aa28b11a
11 changed files with 221 additions and 38 deletions

View File

@ -45,7 +45,7 @@ module.exports = {
'react/jsx-filename-extension': ['error', { extensions: ['.js', '.tsx'] }],
'no-underscore-dangle': [
'error',
{ allow: ['__REDUX_DEVTOOLS_EXTENSION__'] },
{ allow: ['__REDUX_DEVTOOLS_EXTENSION__', '_tippy'] },
],
// this rule can be confusing as it forces some non-intuitive code for variable assignment
'prefer-destructuring': 'off',

View File

@ -0,0 +1,118 @@
// stylelint-disable selector-attribute-name-disallowed-list
$separator: 1px solid theme('colors.white-15');
$radius: theme('borderRadius.sm');
.w-dropdown-button {
// Cosmetic details based on live tooltip placement,
// implemented CSS-only by using Tippy.js `data-placement` and `:has`.
--primary-button-radius-top: #{$radius};
--primary-button-radius-bottom: #{$radius};
--toggle-button-radius-top: #{$radius};
--toggle-button-radius-bottom: #{$radius};
--first-item-border-top: 0;
--last-item-border-top: 0;
// Set each separately so a button thats both first and last is correct.
--first-item-start-start-radius: 0;
--first-item-start-end-radius: 0;
--last-item-end-start-radius: 0;
--last-item-end-end-radius: 0;
display: flex;
// Make sure the tooltip within will match this elements width.
position: relative;
[data-tippy-root] {
// Make sure the tooltip within will match this elements width.
width: 100%;
}
@supports not selector(:has(*)) {
// Use no corner radius and always-on borders if `:has` is not supported.
--primary-button-radius-top: 0;
--primary-button-radius-bottom: 0;
--toggle-button-radius-top: 0;
--toggle-button-radius-bottom: 0;
--first-item-border-top: #{$separator};
--last-item-border-top: #{$separator};
}
&:has([data-placement^='bottom']) {
--primary-button-radius-top: #{$radius};
--primary-button-radius-bottom: 0;
--toggle-button-radius-top: #{$radius};
--toggle-button-radius-bottom: 0;
--last-item-end-start-radius: #{$radius};
--last-item-end-end-radius: #{$radius};
--first-item-border-top: #{$separator};
}
&:has([data-placement^='top']) {
--primary-button-radius-top: 0;
--primary-button-radius-bottom: #{$radius};
--toggle-button-radius-top: 0;
--toggle-button-radius-bottom: #{$radius};
--first-item-start-start-radius: #{$radius};
--first-item-start-end-radius: #{$radius};
--last-item-border-bottom: #{$separator};
}
}
// Primary button next to the dropdown toggle.
.w-dropdown-button > :is(a, button) {
border-start-start-radius: var(--primary-button-radius-top);
border-start-end-radius: 0;
border-end-start-radius: var(--primary-button-radius-bottom);
border-end-end-radius: 0;
min-height: $text-input-height;
}
.w-dropdown-button .button.w-dropdown__toggle {
width: theme('spacing.8');
height: $text-input-height;
padding: 0 theme('spacing.2');
background-color: theme('colors.surface-button-default');
color: theme('colors.text-button');
border-inline-start: $separator;
border-start-start-radius: 0;
border-start-end-radius: var(--toggle-button-radius-top);
border-end-start-radius: 0;
border-end-end-radius: var(--toggle-button-radius-bottom);
&:is(:hover, :focus-visible) {
background-color: theme('colors.surface-button-hover');
}
}
.w-dropdown-button .w-dropdown__content {
padding: 0;
}
// Use a generic selector to support all types of links / buttons.
.w-dropdown-button .w-dropdown__content :is(a, button) {
@include show-focus-outline-inside();
height: auto;
min-height: $text-input-height;
margin: 0;
white-space: normal;
border-radius: 0;
.icon {
opacity: 1;
}
&:nth-child(n + 2) {
border-top: $separator;
}
&:first-child {
border-top: var(--first-item-border-top);
border-start-start-radius: var(--first-item-start-start-radius);
border-start-end-radius: var(--first-item-start-end-radius);
}
&:last-child {
border-bottom: var(--last-item-border-bottom);
border-end-start-radius: var(--last-item-end-start-radius);
border-end-end-radius: var(--last-item-end-end-radius);
}
}

View File

@ -21,6 +21,10 @@
@apply w-w-4 w-h-4 w-mr-3 w-transition w-opacity-50 w-shrink-0;
}
.icon-wrapper .icon {
@apply w-mr-0;
}
&:hover {
@apply w-text-text-label-menus-active w-bg-surface-menu-item-active;

View File

@ -111,6 +111,7 @@ These are classes for components.
@import 'components/dialog';
@import 'components/dismissible';
@import 'components/dropdown';
@import 'components/dropdown-button';
@import 'components/dropdown.legacy';
@import 'components/help-block';
@import 'components/button';

View File

@ -31,9 +31,17 @@
}
}
.tippy-box[data-theme='dropdown-button'] {
@apply w-rounded-none w-w-full w-bg-transparent;
.tippy-content {
@apply w-p-0;
}
}
// Media for Windows High Contrast mode
@media (forced-colors: active) {
.tippy-box[data-theme='dropdown'] {
.tippy-box {
.tippy-content {
border: 2px solid transparent;
}

View File

@ -8,14 +8,12 @@ describe('DropdownController', () => {
beforeEach(async () => {
document.body.innerHTML = `
<section>
<div data-controller="w-dropdown" data-action="custom:show->w-dropdown#show custom:hide->w-dropdown#hide">
<button id="toggle" type="button" data-w-dropdown-target="toggle" aria-label="Actions"></button>
<div data-w-dropdown-target="content">
<a href="/">Option</a>
</div>
</div>
</section>`;
<div data-controller="w-dropdown" data-w-dropdown-theme-value="dropdown" data-action="custom:show->w-dropdown#show custom:hide->w-dropdown#hide">
<button id="toggle" type="button" data-w-dropdown-target="toggle" aria-label="Actions"></button>
<div data-w-dropdown-target="content">
<a href="/">Option</a>
</div>
</div>`;
application = Application.start();
application.register('w-dropdown', DropdownController);
@ -57,6 +55,11 @@ describe('DropdownController', () => {
expect(expandedContent[0].innerHTML).toContain('<a href="/">Option</a>');
});
it('supports providing a theme to Tippy.js', () => {
const toggle = document.querySelector('[data-w-dropdown-target="toggle"]');
expect(toggle._tippy.props.theme).toBe('dropdown');
});
it('triggers custom event on activation', async () => {
const toggle = document.querySelector('[data-w-dropdown-target="toggle"]');
const dropdownElement = document.querySelector(

View File

@ -62,10 +62,12 @@ const hideTooltipOnClickInside = {
* If the toggle button has a toggle arrow,
* rotate it when open and closed.
*/
const rotateToggleIcon = {
export const rotateToggleIcon = {
name: 'rotateToggleIcon',
fn(instance: Instance) {
const dropdownIcon = instance.reference.querySelector('.icon-arrow-down');
const dropdownIcon = instance.reference.querySelector(
'.icon-arrow-down, .icon-arrow-up',
);
if (!dropdownIcon) {
return {};
@ -78,6 +80,21 @@ const rotateToggleIcon = {
},
};
const themeOptions = {
'dropdown': {
arrow: true,
maxWidth: 350,
placement: 'bottom',
},
'dropdown-button': {
arrow: false,
maxWidth: 'none',
placement: 'bottom-start',
},
} as const;
type TippyTheme = keyof typeof themeOptions;
/**
* A Tippy.js tooltip with interactive "dropdown" options.
*
@ -92,6 +109,7 @@ export class DropdownController extends Controller<HTMLElement> {
static values = {
hideOnClick: { default: false, type: Boolean },
offset: Array,
theme: { default: 'dropdown' as TippyTheme, type: String },
};
declare hideOnClickValue: boolean;
@ -101,6 +119,7 @@ export class DropdownController extends Controller<HTMLElement> {
declare readonly hasContentTarget: boolean;
declare readonly hasOffsetValue: boolean;
declare readonly toggleTarget: HTMLButtonElement;
declare readonly themeValue: TippyTheme;
tippy?: Instance<Props>;
@ -144,11 +163,12 @@ export class DropdownController extends Controller<HTMLElement> {
...(this.hasContentTarget
? { content: this.contentTarget as Content }
: {}),
...themeOptions[this.themeValue],
trigger: 'click',
interactive: true,
theme: 'dropdown',
...(this.hasOffsetValue && { offset: this.offsetValue }),
placement: 'bottom',
getReferenceClientRect: () => this.getReference().getBoundingClientRect(),
theme: this.themeValue,
plugins: this.plugins,
onShow() {
if (hoverTooltipInstance) {
@ -173,4 +193,14 @@ export class DropdownController extends Controller<HTMLElement> {
rotateToggleIcon,
].concat(this.hideOnClickValue ? [hideTooltipOnClickInside] : []);
}
/**
* Use a different reference element depending on the theme.
*/
getReference() {
const toggleParent = this.toggleTarget.parentElement as HTMLElement;
return this.themeValue === 'dropdown-button'
? (toggleParent.parentElement as HTMLElement)
: toggleParent;
}
}

View File

@ -1,8 +1,9 @@
{% load wagtailadmin_tags i18n %}
{% load wagtailadmin_tags %}
{% comment "text/markdown" %}
Reusable dropdown menu component built with Tippy.js using the Stimulus DropdownController.
- `theme` (string?) - visual variants of the component
- `classname` (string?) - more classes for parent element
- `attrs` (string?) - more attributes for parent element
- `toggle_icon` (string?) - toggle icon identifier
@ -15,8 +16,10 @@
- `children` - Dropdown contents (`a` and `button` elements only)
{% endcomment %}
<div data-controller="w-dropdown" class="{% classnames 'w-dropdown' classname %}" {{ attrs }} {% if hide_on_click %}data-w-dropdown-hide-on-click-value="true"{% endif %}{% if toggle_tooltip_offset %} data-w-dropdown-offset-value="{{ toggle_tooltip_offset }}"{% endif %}>
<button type="button" class="{% classnames 'w-dropdown__toggle' toggle_label|yesno:',w-dropdown__toggle--icon' toggle_classname %}" data-w-dropdown-target="toggle"{% if toggle_aria_label %} aria-label="{{ toggle_aria_label }}"{% endif %}{% if toggle_describedby %} aria-describedby="{{ toggle_describedby }}"{% endif %}>
{% fragment as class %}{% classnames 'w-dropdown' classname %}{% if theme %} w-dropdown--{{ theme }}{% endif %}{% endfragment %}
<div data-controller="w-dropdown" {% if theme %}data-w-dropdown-theme-value="{{ theme }}"{% endif %} class="{{ class }}" {{ attrs }} {% if hide_on_click %}data-w-dropdown-hide-on-click-value="true"{% endif %}{% if toggle_tooltip_offset %} data-w-dropdown-offset-value="{{ toggle_tooltip_offset }}"{% endif %}>
<button type="button" class="{% classnames 'w-dropdown__toggle' toggle_label|yesno:',w-dropdown__toggle--icon' toggle_classname %}" data-w-dropdown-target="toggle"{% if toggle_aria_label %} aria-label="{{ toggle_aria_label }}"{% endif %}{% if toggle_describedby %} aria-describedby="{{ toggle_describedby }}"{% endif %}{% if toggle_tippy_offset %} data-tippy-offset="{{ toggle_tippy_offset }}"{% endif %}>
{{ toggle_label }}
{% if toggle_icon %}
{% icon name=toggle_icon classname="w-dropdown__toggle-icon" %}

View File

@ -0,0 +1,23 @@
{% load wagtailadmin_tags i18n %}
{% comment "text/markdown" %}
A button with a dropdown menu next to it.
- `button` (fragment) - the main button
- `toggle_icon` (string?) - toggle icon identifier
- `toggle_classname` (string?) - additional toggle classes
- `classname` (string?) - additional component classes
- `children` - Dropdown contents (`a` and `button` elements only)
{% endcomment %}
<div class="{% classnames 'w-dropdown-button' classname %}">
{{ button }}
{% if children %}
{% fragment as toggle_classes %}{% classnames toggle_classname "button" %}{% endfragment %}
{# Built with w-sr-only so there is no visible tooltip. #}
{% fragment as toggle_label %}<span class="w-sr-only">{% trans "More actions" %}</span>{% endfragment %}
{% dropdown theme="dropdown-button" toggle_label=toggle_label toggle_classname=toggle_classes toggle_icon=toggle_icon|default:"arrow-down" toggle_tooltip_offset="[0, 0]" %}
{{ children }}
{% enddropdown %}
{% endif %}
</div>

View File

@ -1114,6 +1114,13 @@ class DropdownNode(BlockInclusionNode):
register.tag("dropdown", DropdownNode.handle)
class DropdownButtonNode(BlockInclusionNode):
template = "wagtailadmin/shared/dropdown/dropdown_button.html"
register.tag("dropdown_button", DropdownButtonNode.handle)
class PanelNode(BlockInclusionNode):
template = "wagtailadmin/shared/panel.html"

View File

@ -1890,28 +1890,14 @@ class TestPageEdit(WagtailTestUtils, TestCase):
reverse("wagtailadmin_pages:edit", args=(self.single_event_page.id,))
)
publish_button = """
<button type="submit" name="action-publish" value="action-publish" class="button button-longrunning " data-controller="w-progress" data-action="w-progress#activate" data-w-progress-active-value="Publishing…">
<svg class="icon icon-upload button-longrunning__icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
soup = self.get_soup(response.content)
<svg class="icon icon-spinner icon" aria-hidden="true"><use href="#icon-spinner"></use></svg><em data-w-progress-target="label">Publish</em>
</button>
"""
save_button = """
<button type="submit" class="button action-save button-longrunning " data-controller="w-progress" data-action="w-progress#activate" data-w-progress-active-value="Saving…" >
<svg class="icon icon-draft button-longrunning__icon" aria-hidden="true"><use href="#icon-draft"></use></svg>
<svg class="icon icon-spinner icon" aria-hidden="true"><use href="#icon-spinner"></use></svg>
<em data-w-progress-target="label">Save draft</em>
</button>
"""
# save button should be in a <li>
self.assertContains(response, "<li>%s</li>" % save_button, html=True)
# publish button should be present, but not in a <li>
self.assertContains(response, publish_button, html=True)
self.assertNotContains(response, "<li>%s</li>" % publish_button, html=True)
# save button should be inside "More actions" toggle.
save_button = soup.select_one(".w-dropdown__content .action-save")
self.assertIsNotNone(save_button)
# publish button should be directly inside "Dropdown button".
publish_button = soup.select_one('.w-dropdown-button > [name="action-publish"]')
self.assertIsNotNone(publish_button)
def test_override_publish_action_menu_item_label(self):
def hook_func(menu_items, request, context):