Skip to content

Commit

Permalink
Merge pull request #13019 from rak-phillip/chore/12771-dropdown-compo…
Browse files Browse the repository at this point in the history
…nent

Create accessible dropdown component
  • Loading branch information
rak-phillip authored Feb 6, 2025
2 parents 202b768 + e9b6d19 commit 57d3a96
Show file tree
Hide file tree
Showing 24 changed files with 722 additions and 289 deletions.
4 changes: 2 additions & 2 deletions cypress/e2e/po/side-bars/page-actions.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default class PageActionsPo extends ComponentPo {
* @returns {Cypress.Chainable}
*/
private static pageActionsMenu(): Cypress.Chainable {
return cy.get('body').getId('page-actions-dropdown');
return cy.get('body').find('[dropdown-menu-collection]');
}

/**
Expand All @@ -41,7 +41,7 @@ export default class PageActionsPo extends ComponentPo {
*/
links(): Cypress.Chainable {
return PageActionsPo.open().then(() => {
PageActionsPo.pageActionsMenu().find('.user-menu-item');
PageActionsPo.pageActionsMenu().find('[dropdown-menu-item]');
});
}

Expand Down
26 changes: 4 additions & 22 deletions cypress/e2e/po/side-bars/user-menu.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,14 @@ export default class UserMenuPo extends ComponentPo {
*
*/
private userMenuContainer() {
return this.self().get('.user-menu');
}

/**
* Our section within the transient userMenuContainer
*/
userMenu(): Cypress.Chainable {
return this.self().getId(`user-menu-dropdown`);
return cy.get('body').find('[dropdown-menu-collection]');
}

/**
* Open the user menu
*
* Multiple clicks because sometimes just one ... isn't enough
*
*/
open(): Cypress.Chainable {
this.self().click();
this.self().click();
this.self().click();
this.self().click();
return cy.getId('nav_header_showUserMenu').should('be.visible').click();
}

/**
Expand All @@ -46,18 +33,13 @@ export default class UserMenuPo extends ComponentPo {
isOpen() {
// These should fail if `visibility: hidden` - https://docs.cypress.io/guides/core-concepts/interacting-with-elements#Visibility
this.userMenuContainer().should('be.visible');
this.userMenu().should('be.visible');
}

ensureOpen() {
// Check the user avatar icon is there
this.checkVisible();

// Yep, these are _horrible_, but flakey user avatar tests have plagued us for months and no-one has yet fixed them
// This is a temporary step until that brave, tenacious champion of e2e resolves the underlying issue.
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
this.open();
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting

// Check the v-popper drop down is open, if not open it
// This isn't a pattern we want to use often, but this area has caused us lots of issues
Expand All @@ -84,15 +66,15 @@ export default class UserMenuPo extends ComponentPo {
* Check if menu is closed
*/
isClosed() {
this.userMenu().should('not.exist');
this.userMenuContainer().should('not.exist');
}

/**
* Get menu items
* @returns
*/
getMenuItems(): Cypress.Chainable {
return this.userMenu().find('li').should('be.visible').and('have.length', 4);
return this.userMenuContainer().find('[dropdown-menu-item]').should('be.visible').and('have.length', 3);
}

/**
Expand Down
8 changes: 8 additions & 0 deletions pkg/rancher-components/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ module.exports = {
'error',
{ 'ts-nocheck': false }
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
],
'vue/one-component-per-file': 'off',
'vue/no-deprecated-slot-attribute': 'off',
'vue/require-explicit-emits': 'off',
Expand Down
90 changes: 90 additions & 0 deletions pkg/rancher-components/src/components/RcButton/RcButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* A button element used for performing actions, such as submitting forms or
* opening dialogs.
*
* Example:
*
* <rc-button primary @click="doAction">Perform an Action</rc-button>
*/
import { computed, ref, defineExpose } from 'vue';
import { ButtonRoleProps, ButtonSizeProps } from './types';
const buttonRoles: { role: keyof ButtonRoleProps, className: string }[] = [
{ role: 'primary', className: 'role-primary' },
{ role: 'secondary', className: 'role-secondary' },
{ role: 'tertiary', className: 'role-tertiary' },
{ role: 'link', className: 'role-link' },
{ role: 'ghost', className: 'role-ghost' },
];
const buttonSizes: { size: keyof ButtonSizeProps, className: string }[] = [
{ size: 'small', className: 'btn-sm' },
];
const props = defineProps<ButtonRoleProps & ButtonSizeProps>();
const buttonClass = computed(() => {
const activeRole = buttonRoles.find(({ role }) => props[role]);
const isButtonSmall = buttonSizes.some(({ size }) => props[size]);
return {
btn: true,
[activeRole?.className || 'role-primary']: true,
'btn-sm': isButtonSmall,
};
});
const RcFocusTarget = ref<HTMLElement | null>(null);
const focus = () => {
RcFocusTarget?.value?.focus();
};
defineExpose({ focus });
</script>

<template>
<button
ref="RcFocusTarget"
role="button"
:class="{ ...buttonClass, ...($attrs.class || { }) }"
>
<slot name="before">
<!-- Empty Content -->
</slot>
<slot>
<!-- Empty Content -->
</slot>
<slot name="after">
<!-- Empty Content -->
</slot>
</button>
</template>

<style lang="scss" scoped>
.role-link {
&:focus, &.focused {
outline: var(--outline-width) solid var(--border);
box-shadow: 0 0 0 var(--outline-width) var(--outline);
}
}
button {
&.role-ghost {
padding: 0;
background-color: transparent;
&:focus, &.focused {
outline: 2px solid var(--primary-keyboard-focus);
outline-offset: 0;
}
&:focus-visible {
outline: 2px solid var(--primary-keyboard-focus);
outline-offset: 0;
}
}
}</style>
2 changes: 2 additions & 0 deletions pkg/rancher-components/src/components/RcButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as RcButton } from './RcButton.vue';
export type { RcButtonType } from './types';
17 changes: 17 additions & 0 deletions pkg/rancher-components/src/components/RcButton/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// TODO: 13211 Investigate why `InstanceType<typeof RcButton>` fails prod builds
// export type RcButtonType = InstanceType<typeof RcButton>
export type RcButtonType = {
focus: () => void;
}

export type ButtonRoleProps = {
primary?: boolean;
secondary?: boolean;
tertiary?: boolean;
link?: boolean;
ghost?: boolean;
}

export type ButtonSizeProps = {
small?: boolean;
}
111 changes: 111 additions & 0 deletions pkg/rancher-components/src/components/RcDropdown/RcDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script setup lang="ts">
/**
* Offers a list of choices to the user, such as a set of actions or functions.
* Opened by activating RcDropdownTrigger.
*
* Example:
*
* <rc-dropdown :aria-label="t('nav.actionMenu.label')">
* <rc-dropdown-trigger tertiary>
* <i class="icon icon-actions" />
* </rc-dropdown-trigger>
* <template #dropdownCollection>
* <rc-dropdown-item @click="performAction()">
* Action 1
* </rc-dropdown-item>
* <rc-dropdown-separator />
* <rc-dropdown-item @click="performAction()">
* Action 2
* </rc-dropdown-item>
* </template>
* </rc-dropdown>
*/
import { useTemplateRef } from 'vue';
import { useClickOutside } from '@shell/composables/useClickOutside';
import { useDropdownContext } from '@components/RcDropdown/useDropdownContext';
defineProps<{
ariaLabel?: string
}>();
const {
isMenuOpen,
showMenu,
returnFocus,
setFocus,
provideDropdownContext,
registerDropdownCollection,
} = useDropdownContext();
provideDropdownContext();
const popperContainer = useTemplateRef<HTMLElement>('popperContainer');
const dropdownTarget = useTemplateRef<HTMLElement>('dropdownTarget');
useClickOutside(dropdownTarget, () => showMenu(false));
const applyShow = () => {
registerDropdownCollection(dropdownTarget.value);
setFocus();
};
</script>

<template>
<v-dropdown
no-auto-focus
:triggers="[]"
:shown="isMenuOpen"
:auto-hide="false"
:container="popperContainer"
:placement="'bottom-end'"
@apply-show="applyShow"
>
<slot name="default">
<!--Empty slot content Trigger-->
</slot>

<template #popper>
<div
ref="dropdownTarget"
role="menu"
aria-orientation="vertical"
dropdown-menu-collection
:aria-label="ariaLabel || 'Dropdown Menu'"
>
<slot name="dropdownCollection">
<!--Empty slot content-->
</slot>
</div>
</template>
</v-dropdown>
<div
ref="popperContainer"
class="popperContainer"
@keydown.tab="showMenu(false)"
@keydown.escape="returnFocus"
>
<!--Empty container for mounting popper content-->
</div>
</template>

<style lang="scss" scoped>
.popperContainer {
display: contents;
&:deep(.v-popper__popper) {
.v-popper__wrapper {
box-shadow: 0px 6px 18px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.15);
border-radius: var(--border-radius-lg);
.v-popper__arrow-container {
display: none;
}
.v-popper__inner {
padding: 10px 0 10px 0;
}
}
}
}
</style>
Loading

0 comments on commit 57d3a96

Please sign in to comment.