Skip to content

Commit

Permalink
feat: useSelect (#57)
Browse files Browse the repository at this point in the history
* feat: initial useSelect implementation

* feat: rework options registeration for focus management

* feat: implement continguous select

* feat: select all items with meta + a

* feat: implement home and end selections

* feat: basic distinction between option and value with value extractor

* feat: support activedescendand

* feat: simple useGroup abstraction

* feat: add basic popover controller

* feat: several improvements to the select API

* feat: implement option finder

* chore: barrel helpers directory

* test: use key pressed tests

* fix: better implementation for keypresse

* test: use scope dispose more

* test: added tests for popover controller

* test: added option group test

* test: use option minor tests

* test: select baseline axe tests

* feat: finalize select tests

* feat: added page up and page down behaviors

* feat: remove arrow left/right behavior which is not standard

* feat: drop get value for simpler API
  • Loading branch information
logaretm authored Aug 30, 2024
1 parent 4f9b891 commit f15319d
Show file tree
Hide file tree
Showing 36 changed files with 1,965 additions and 196 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/a11y/useLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createRefCapture } from '../utils/common';
interface LabelProps {
for: MaybeRefOrGetter<string>;
label: MaybeRefOrGetter<Maybe<string>>;
targetRef?: MaybeRefOrGetter<HTMLElement | undefined>;
targetRef?: MaybeRefOrGetter<Maybe<HTMLElement>>;
handleClick?: () => void;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const FieldTypePrefixes = {
Slider: 'sl',
SearchField: 'sf',
FormGroup: 'fg',
Select: 'se',
Option: 'opt',
OptionGroup: 'og',
} as const;

export const NOOP = () => {};
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/helpers/useButtonHold/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useButtonHold';
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MaybeRefOrGetter, onBeforeUnmount, toValue } from 'vue';
import { MaybeRefOrGetter, toValue } from 'vue';
import { tryOnScopeDispose } from '../../utils/common';

export interface ButtonHoldOptions {
onHoldTick: () => void;
Expand Down Expand Up @@ -55,7 +56,7 @@ export function useButtonHold(opts: ButtonHoldOptions) {
onMousedown,
};

onBeforeUnmount(() => {
tryOnScopeDispose(() => {
document.removeEventListener('mouseup', onMouseup);
clearAll();
});
Expand Down
47 changes: 0 additions & 47 deletions packages/core/src/helpers/useEventListener.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/core/src/helpers/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useEventListener';
48 changes: 48 additions & 0 deletions packages/core/src/helpers/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MaybeRefOrGetter, toValue, watch } from 'vue';
import { Arrayable, Maybe } from '../../types';
import { normalizeArrayable, tryOnScopeDispose } from '../../utils/common';

interface ListenerOptions {
disabled?: MaybeRefOrGetter<boolean>;
}

export function useEventListener<TEvent extends Event>(
targetRef: MaybeRefOrGetter<Maybe<EventTarget>>,
event: Arrayable<string>,
listener: (e: TEvent) => unknown,
opts?: ListenerOptions,
) {
let controller: AbortController | undefined;
function cleanup() {
controller?.abort();
}

function setup(el: EventTarget) {
if (toValue(opts?.disabled)) {
return;
}

controller = new AbortController();
const events = normalizeArrayable(event);
const listenerOpts = { signal: controller.signal };
events.forEach(evt => {
el.addEventListener(evt, listener as EventListener, listenerOpts);
});
}

const stopWatch = watch(
() => [toValue(targetRef), toValue(opts?.disabled)] as const,
([el, disabled]) => {
cleanup();
if (el && !disabled) {
setup(el);
}
},
{ immediate: true },
);

tryOnScopeDispose(() => {
cleanup();
stopWatch();
});
}
1 change: 1 addition & 0 deletions packages/core/src/helpers/useKeyPressed/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useKeyPressed';
78 changes: 78 additions & 0 deletions packages/core/src/helpers/useKeyPressed/useKeyPressed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { renderSetup } from '@test-utils/index';
import { useKeyPressed } from './useKeyPressed';
import { fireEvent } from '@testing-library/vue';
import { nextTick, ref } from 'vue';

describe('ref is true as long as the key is held', () => {
test('accepts single key string', async () => {
const { isPressed } = await renderSetup(() => {
return { isPressed: useKeyPressed('ShiftLeft') };
});

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'ShiftLeft' });
expect(isPressed.value).toBe(true);
await fireEvent.keyUp(window, { code: 'ShiftLeft' });
expect(isPressed.value).toBe(false);
});

test('accepts multiple key strings', async () => {
const { isPressed } = await renderSetup(() => {
return { isPressed: useKeyPressed(['KeyK', 'KeyL']) };
});

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'KeyK' });
expect(isPressed.value).toBe(true);
await fireEvent.keyUp(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);

await fireEvent.keyDown(window, { code: 'KeyL' });
expect(isPressed.value).toBe(true);
await fireEvent.keyUp(window, { code: 'KeyL' });
expect(isPressed.value).toBe(false);
});

test('accepts a predicate', async () => {
const { isPressed } = await renderSetup(() => {
return { isPressed: useKeyPressed(e => e.code === 'KeyK') };
});

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'KeyK' });
expect(isPressed.value).toBe(true);
await fireEvent.keyUp(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);
});
});

test('can be disabled', async () => {
const isDisabled = ref(true);
const { isPressed } = await renderSetup(() => {
return { isPressed: useKeyPressed('KeyK', isDisabled) };
});

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);
await fireEvent.keyUp(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);

isDisabled.value = false;
await nextTick();

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'KeyK' });
expect(isPressed.value).toBe(true);
await fireEvent.keyUp(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);

isDisabled.value = true;
await nextTick();

expect(isPressed.value).toBe(false);
await fireEvent.keyDown(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);
await fireEvent.keyUp(window, { code: 'KeyK' });
expect(isPressed.value).toBe(false);
});
46 changes: 46 additions & 0 deletions packages/core/src/helpers/useKeyPressed/useKeyPressed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { MaybeRefOrGetter, shallowRef } from 'vue';
import { useEventListener } from '../useEventListener';
import { Arrayable } from '../../types';
import { isCallable, normalizeArrayable } from '../../utils/common';

export function useKeyPressed(
codes: Arrayable<string> | ((evt: KeyboardEvent) => boolean),
disabled?: MaybeRefOrGetter<boolean>,
) {
const isPressed = shallowRef(false);
const predicate = isCallable(codes) ? codes : (e: KeyboardEvent) => normalizeArrayable(codes).includes(e.code);
function onKeydown(e: KeyboardEvent) {
if (predicate(e)) {
isPressed.value = true;
}
}

// We don't care if the multiple keys can be pressed to trigger it initially
// because it's a rare case and it's not worth the complexity, user can split it into multiple hooks
function onKeyup(e: KeyboardEvent) {
if (predicate(e)) {
isPressed.value = false;
}
}

useEventListener(
window,
'keydown',
e => {
onKeydown(e as KeyboardEvent);
},
{ disabled },
);

useEventListener(
window,
'keyup',
e => {
const keyEvt = e as KeyboardEvent;
onKeyup(keyEvt);
},
{ disabled: () => !isPressed.value },
);

return isPressed;
}
1 change: 1 addition & 0 deletions packages/core/src/helpers/usePopoverController/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './usePopoverController';
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/vue';
import { usePopoverController } from './usePopoverController';
import { nextTick, ref } from 'vue';

// The matches query doesn't seem to be supported
test.skip('opens/closes the popover when `isOpen` changes', async () => {
await render({
setup() {
const popoverRef = ref<HTMLElement>();
const { isOpen } = usePopoverController(popoverRef);

return {
isOpen,
popoverRef,
};
},
template: `<div ref="popoverRef" data-testid="popover" popover>visible</div> <button @click="isOpen = !isOpen">Toggle</button`,
});

expect(screen.getByTestId('popover').matches(':popover-open')).toBe(false);
await fireEvent.click(screen.getByText('Toggle'));
expect(screen.getByTestId('popover').matches(':popover-open')).toBe(true);
await fireEvent.click(screen.getByText('Toggle'));
expect(screen.getByTestId('popover').matches(':popover-open')).toBe(false);
});

const createEvent = (state: boolean) => {
const evt = new Event('toggle');
(evt as any).newState = state ? 'open' : 'closed';

Check warning on line 29 in packages/core/src/helpers/usePopoverController/usePopoverController.spec.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

return evt;
};

test('Syncs isOpen when the toggle event is fired', async () => {
await render({
setup() {
const popoverRef = ref<HTMLElement>();
const { isOpen } = usePopoverController(popoverRef);

return {
isOpen,
popoverRef,
};
},
template: `
<div ref="popoverRef" data-testid="popover" popover>visible</div>
<span data-testid="state">{{ isOpen }}</span>
`,
});

expect(screen.getByTestId('state')).toHaveTextContent('false');
await fireEvent(screen.getByTestId('popover'), createEvent(true));
await nextTick();
expect(screen.getByTestId('state')).toHaveTextContent('true');
await fireEvent(screen.getByTestId('popover'), createEvent(false));
await nextTick();
expect(screen.getByTestId('state')).toHaveTextContent('false');
});

test('No ops if state match', async () => {
await render({
setup() {
const popoverRef = ref<HTMLElement>();
const { isOpen } = usePopoverController(popoverRef);

return {
isOpen,
popoverRef,
};
},
template: `
<div ref="popoverRef" data-testid="popover" popover>visible</div>
<span data-testid="state">{{ isOpen }}</span>
`,
});

expect(screen.getByTestId('state')).toHaveTextContent('false');
await fireEvent(screen.getByTestId('popover'), createEvent(false));
await nextTick();
expect(screen.getByTestId('state')).toHaveTextContent('false');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Ref, shallowRef, watch } from 'vue';
import { useEventListener } from '../useEventListener';
import { Maybe } from '../../types';

export function usePopoverController(popoverEl: Ref<Maybe<HTMLElement>>) {
const isOpen = shallowRef(false);

watch(isOpen, value => {
const el = popoverEl.value;
if (!el || !el.popover) {
return;
}

if (value === el.matches(':popover-open')) {
return;
}

if (value) {
el.showPopover();
return;
}

el.hidePopover();
});

useEventListener(popoverEl, 'toggle', (e: ToggleEvent) => {
const shouldBeOpen = e.newState === 'open';
if (isOpen.value === shouldBeOpen) {
return;
}

isOpen.value = shouldBeOpen;
});

return {
isOpen,
};
}
Loading

0 comments on commit f15319d

Please sign in to comment.