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:
parent
89aa153168
commit
91aa28b11a
@ -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',
|
||||
|
118
client/scss/components/_dropdown-button.scss
Normal file
118
client/scss/components/_dropdown-button.scss
Normal 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 that’s 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 element’s width.
|
||||
position: relative;
|
||||
|
||||
[data-tippy-root] {
|
||||
// Make sure the tooltip within will match this element’s 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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" %}
|
||||
|
@ -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>
|
@ -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"
|
||||
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user