Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/example/e2e/overlays/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ test.describe('menu', () => {
await expect(activator).toBeFocused();
});

test('escape closes a nested menu one level at a time', async ({ page }) => {
const outer = page.locator('div[data-id=menu-nested]');
const innerContent = page.locator('[data-id=inner-content]');

await outer.locator('[data-id=activator]').click();
await expect(page.locator(menuContent)).toBeVisible();

await page.locator('[data-id=inner-activator]').click();
await expect(innerContent).toBeVisible();

// the first escape closes the inner menu only
await page.keyboard.press('Escape');
await expect(innerContent).toHaveCount(0);
await expect(page.locator(menuContent)).toBeVisible();

// the second closes the outer one
await page.keyboard.press('Escape');
await expect(page.locator(menuContent)).toHaveCount(0);
});

test('menu should be closed by clicking the menu content', async ({ page }) => {
const menu = page.locator('div[data-id=menu-12]');
const activator = menu.locator('[data-id=activator]');
Expand Down
45 changes: 45 additions & 0 deletions apps/example/src/views/MenuView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,50 @@ const menus = ref<SimpleMenu[]>([
</RuiMenu>
</div>
</div>

<h6 class="text-h6 mt-8 mb-4">
Nested menu
</h6>

<div class="py-4">
<RuiMenu
data-id="menu-nested"
:open-delay="10"
:popper="{ placement: 'bottom' }"
>
<template #activator="{ attrs }">
<RuiButton
color="primary"
v-bind="{ ...attrs, 'data-id': 'activator' }"
>
Open outer menu
</RuiButton>
</template>
<div class="px-3 py-2 flex flex-col items-start gap-2">
<span>This is the outer menu</span>
<RuiMenu
data-id="menu-nested-inner"
:open-delay="10"
:popper="{ placement: 'right' }"
>
<template #activator="{ attrs }">
<RuiButton
color="secondary"
size="sm"
v-bind="{ ...attrs, 'data-id': 'inner-activator' }"
>
Open inner menu
</RuiButton>
</template>
<div
class="px-3 py-2"
data-id="inner-content"
>
This is the inner menu
</div>
</RuiMenu>
</div>
</RuiMenu>
</div>
</ComponentView>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -2915,5 +2915,42 @@ describe('components/date-time-picker/RuiDateTimePicker.vue', () => {

expect(document.activeElement).toBe(wrapper.find('input').element);
});

it('highlights the first segment of a filled field', async () => {
wrapper = createWrapper({
attachTo: document.body,
props: {
autofocus: true,
modelValue: new Date(2023, 0, 15, 10, 30),
},
});

await vi.runOnlyPendingTimersAsync();

const input = wrapper.find('input').element;
expect(input.value).toBe('15/01/2023 10:30');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});

it('highlights the first segment of an empty field, once the tokens land', async () => {
wrapper = createWrapper({
attachTo: document.body,
props: {
allowEmpty: true,
autofocus: true,
modelValue: undefined,
},
});

await vi.runOnlyPendingTimersAsync();

// focusing expands the format tokens into the value; the caret must end
// up on the first segment rather than after the tokens
const input = wrapper.find('input').element;
expect(input.value).toBe('DD/MM/YYYY HH:mm');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
showTimezone = false,
actions = ['now'],
autofocus = false,
} = defineProps<RuiDateTimePickerProps>();

Check warning on line 96 in packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue

View workflow job for this annotation

GitHub Actions / Lint & typecheck

Component has too many props (19). Maximum allowed is 6

Check warning on line 96 in packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue

View workflow job for this annotation

GitHub Actions / Lint & typecheck

Component has too many props (19). Maximum allowed is 6

defineSlots<{
'menu-content': () => any;
Expand Down Expand Up @@ -187,6 +187,7 @@
handleKeyDown,
handleMouseDown,
handlePaste,
selectFirstSegment,
setSegment,
} = useKeyboardHandler({
accuracy,
Expand Down Expand Up @@ -252,6 +253,18 @@
return result;
});

/**
* An untouched field holds no value until focus expands the format tokens into
* it. That patch overwrites the input's value, and writing a value drops any
* selection with it, so the highlight `handleFocus` just set is gone by the
* time the tokens are on screen and the caret ends up after them. Re-select
* the first segment once the tokens have actually landed.
*/
watch(formattedDisplay, (value, previous) => {
if (previous === '' && value !== '' && get(searchInputFocused))
selectFirstSegment();
}, { flush: 'post' });

const timeSelection = computed<TimePickerSelection>({
get() {
const type = getCurrentSegment()?.type;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ export function useKeyboardHandler(options: KeyboardHandlerOptions) {
set(cursorPosition, target.selectionStart ?? 0);
}

function selectFirstSegment(): void {
const firstSegment = get(segmentPositions)[0];
if (firstSegment)
setCursorPosition(firstSegment);
}

function handleFocus(): void {
// If we just clicked on a segment, restore that selection
// (the selection may have been lost due to menu opening/DOM updates)
Expand All @@ -291,9 +297,7 @@ export function useKeyboardHandler(options: KeyboardHandlerOptions) {
const input = get(textInput);
if (input && input.selectionStart !== input.selectionEnd)
return;
const firstSegment = get(segmentPositions)[0];
if (firstSegment)
setCursorPosition(firstSegment);
selectFirstSegment();
}

function handleBlur(): void {
Expand Down Expand Up @@ -348,6 +352,7 @@ export function useKeyboardHandler(options: KeyboardHandlerOptions) {
handleKeyDown,
handleMouseDown,
handlePaste,
selectFirstSegment,
setSegment,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,71 @@ describe('components/overlays/menu/RuiMenu.vue', () => {
});
});

describe('escape propagation', () => {
const Host = defineComponent({
components: { RuiButton, RuiMenu },
emits: ['ancestor-keydown'],
template: `
<div @keydown="$emit('ancestor-keydown')">
<RuiMenu>
<template #activator="{ attrs }">
<RuiButton id="trigger" v-bind="attrs">Click me!</RuiButton>
</template>
<div>${text}</div>
</RuiMenu>
</div>`,
});

function createHost(): VueWrapper<InstanceType<typeof Host>> {
return mount(Host);
}

function ancestorKeydowns(host: VueWrapper<InstanceType<typeof Host>>): number {
return host.emitted('ancestor-keydown')?.length ?? 0;
}

it('should let escape through to an ancestor while closed', async () => {
const host = createHost();

await host.find('#trigger').trigger('keydown', { key: 'Escape' });

expect(ancestorKeydowns(host)).toBe(1);

host.unmount();
});

it('should swallow escape while open and close the menu', async () => {
const host = createHost();

await host.find('#trigger').trigger('click');
await vi.runAllTimersAsync();
expect(document.body.innerHTML).toMatch(new RegExp(text));

const before = ancestorKeydowns(host);
await host.find('#trigger').trigger('keydown', { key: 'Escape' });
await vi.runAllTimersAsync();

expect(ancestorKeydowns(host)).toBe(before);
expect(document.body.innerHTML).not.toMatch(new RegExp(text));

host.unmount();
});

it('should let other keys through while open', async () => {
const host = createHost();

await host.find('#trigger').trigger('click');
await vi.runAllTimersAsync();

const before = ancestorKeydowns(host);
await host.find('#trigger').trigger('keydown', { key: 'a' });

expect(ancestorKeydowns(host)).toBe(before + 1);

host.unmount();
});
});

it('should not close when persistent and clicking outside', async () => {
wrapper = createWrapper({
props: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,14 @@ onClickOutside(menu, () => {
</script>

<template>
<!--
Escape is swallowed only while the menu is open (`onLeave` stops the event
itself); a closed menu must let it through, or a consumer that closes its
own editor on Escape never sees the key.
-->
<div
:class="classNames?.root"
@keydown.esc.stop="onLeave()"
@keydown.esc="onLeave($event)"
>
<div
ref="activator"
Expand All @@ -299,7 +304,7 @@ onClickOutside(menu, () => {
:role="role"
:data-placement="currentPlacement"
@click="closeOnContentClick ? onLeave() : undefined"
@keydown.esc.stop="onLeave()"
@keydown.esc="onLeave($event)"
>
<TransitionGroup
enter-active-class="transition ease-out duration-200"
Expand Down
Loading