Skip to content

Commit

Permalink
feat: Refactor toggle directive into popover component (#983)
Browse files Browse the repository at this point in the history
* refactor(dropdown): Migrated to popover component
* extracted a base option-like class for dropdown item and select item
* utility classes refactoring
* refactor(select): Use the underlying popover
* Improved behavior to mimic the native select
* WAI-ARIA improvements
* refactor(dropdown): Detached target behavior
* refactor: Moved keybindings to handleEvent pattern
* chore: Improved storybook samples
* refactor: RootClickControllerConfig as type
Co-authored-by: Simeon Simeonoff <sim.simeonoff@gmail.com>
  • Loading branch information
rkaraivanov authored Dec 5, 2023
1 parent 56a37af commit 3a4d52e
Show file tree
Hide file tree
Showing 26 changed files with 3,982 additions and 2,585 deletions.
5 changes: 5 additions & 0 deletions .storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { addons } from '@storybook/manager-api';

addons.setConfig({
enableShortcuts: false,
});
6 changes: 6 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { html } from 'lit';
import { configureTheme } from '../src/theming/config';
import type { Decorator } from '@storybook/web-components';
import { withActions } from '@storybook/addon-actions/decorator';
import { configureActions } from '@storybook/addon-actions';

configureActions({
clearOnStoryChange: true,
limit: 5,
});

type ThemeImport = { default: string };

Expand Down
45 changes: 32 additions & 13 deletions src/components/common/controllers/key-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const arrowUp = 'ArrowUp' as const;
export const arrowDown = 'ArrowDown' as const;
export const enterKey = 'Enter' as const;
export const spaceBar = ' ' as const;
export const escapeKey = 'Escape' as const;
export const homeKey = 'Home' as const;
export const endKey = 'End' as const;
export const pageUpKey = 'PageUp' as const;
Expand All @@ -22,6 +23,7 @@ export const shiftKey = 'Shift' as const;

/* Types */
export type KeyBindingHandler = (event: KeyboardEvent) => void;
export type KeyBindingObserverCleanup = { unsubscribe: () => void };

/**
* Whether the current event should be ignored by the controller.
Expand Down Expand Up @@ -169,14 +171,39 @@ export function parseKeys(keys: string | string[]) {
class KeyBindingController implements ReactiveController {
protected _host: ReactiveControllerHost & Element;
protected _ref?: Ref;
protected _observedElement?: Element;
protected _options?: KeyBindingControllerOptions;
private bindings = new Set<KeyBinding>();
private pressedKeys = new Set<string>();

protected get _element() {
if (this._observedElement) {
return this._observedElement;
}
return this._ref ? this._ref.value : this._host;
}

/**
* Sets the controller to listen for keyboard events on an arbitrary `element` in the page context.
* All the configuration and event handlers are applied as well.
*
* Returns an object with an `unsubscribe` function which should be called when the observing of keyboard
* events on the `element` should cease.
*/
public observeElement(element: Element): KeyBindingObserverCleanup {
element.addEventListener('keydown', this);
element.addEventListener('keyup', this);
this._observedElement = element;

return {
unsubscribe: () => {
this._observedElement?.removeEventListener('keydown', this);
this._observedElement?.removeEventListener('keyup', this);
this._observedElement = undefined;
},
};
}

constructor(
host: ReactiveControllerHost & Element,
options?: KeyBindingControllerOptions
Expand Down Expand Up @@ -226,7 +253,7 @@ class KeyBindingController implements ReactiveController {
return false;
}

private _handleEvent(event: KeyboardEvent) {
public handleEvent(event: KeyboardEvent) {
const key = event.key.toLowerCase();
const path = event.composedPath();
const skip = this._options?.skip;
Expand Down Expand Up @@ -274,14 +301,6 @@ class KeyBindingController implements ReactiveController {
}
}

private onKeyUp = (event: Event) => {
this._handleEvent(event as KeyboardEvent);
};

private onKeyDown = (event: Event) => {
this._handleEvent(event as KeyboardEvent);
};

/**
* Registers a keybinding handler.
*/
Expand Down Expand Up @@ -317,13 +336,13 @@ class KeyBindingController implements ReactiveController {
}

public hostConnected(): void {
this._host.addEventListener('keyup', this.onKeyUp);
this._host.addEventListener('keydown', this.onKeyDown);
this._host.addEventListener('keyup', this);
this._host.addEventListener('keydown', this);
}

public hostDisconnected(): void {
this._host.removeEventListener('keyup', this.onKeyUp);
this._host.removeEventListener('keydown', this.onKeyDown);
this._host.removeEventListener('keyup', this);
this._host.removeEventListener('keydown', this);
}
}

Expand Down
78 changes: 78 additions & 0 deletions src/components/common/controllers/root-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';

type RootClickControllerConfig = {
hideCallback?: Function;
target?: HTMLElement;
};

type RootClickControllerHost = ReactiveControllerHost &
HTMLElement & {
open: boolean;
keepOpenOnOutsideClick: boolean;
hide(): void;
};

class RootClickController implements ReactiveController {
constructor(
private readonly host: RootClickControllerHost,
private config?: RootClickControllerConfig
) {
this.host.addController(this);
}

private addEventListeners() {
if (!this.host.keepOpenOnOutsideClick) {
document.addEventListener('click', this);
}
}

private removeEventListeners() {
document.removeEventListener('click', this);
}

private configureListeners() {
this.host.open ? this.addEventListeners() : this.removeEventListeners();
}

public handleEvent(event: MouseEvent) {
if (this.host.keepOpenOnOutsideClick) {
return;
}

const path = event.composed ? event.composedPath() : [event.target];
const target = this.config?.target || null;
if (path.includes(this.host) || path.includes(target)) {
return;
}

this.hide();
}

private hide() {
this.config?.hideCallback
? this.config.hideCallback.call(this.host)
: this.host.hide();
}

public update(config?: RootClickControllerConfig) {
if (config) {
this.config = { ...this.config, ...config };
}
this.configureListeners();
}

public hostConnected() {
this.configureListeners();
}

public hostDisconnected() {
this.removeEventListeners();
}
}

export function addRootClickHandler(
host: RootClickControllerHost,
config?: RootClickControllerConfig
) {
return new RootClickController(host, config);
}
98 changes: 98 additions & 0 deletions src/components/common/controllers/root-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';

type RootScrollControllerConfig = {
hideCallback?: Function;
resetListeners?: boolean;
};

type RootScrollControllerHost = ReactiveControllerHost & {
open: boolean;
hide(): void;
scrollStrategy?: 'scroll' | 'close' | 'block';
};

type ScrollRecord = { scrollTop: number; scrollLeft: number };

class RootScrollController implements ReactiveController {
private _cache: WeakMap<Element, ScrollRecord>;

constructor(
private readonly host: RootScrollControllerHost,
private config?: RootScrollControllerConfig
) {
this._cache = new WeakMap();
this.host.addController(this);
}

private configureListeners() {
this.host.open ? this.addEventListeners() : this.removeEventListeners();
}

private hide() {
this.config?.hideCallback
? this.config.hideCallback.call(this.host)
: this.host.hide();
}

private addEventListeners() {
if (this.host.scrollStrategy !== 'scroll') {
document.addEventListener('scroll', this, { capture: true });
}
}

private removeEventListeners() {
document.removeEventListener('scroll', this, { capture: true });
this._cache = new WeakMap();
}

public handleEvent(event: Event) {
this.host.scrollStrategy === 'close' ? this.hide() : this._block(event);
}

private _block(event: Event) {
event.preventDefault();
const element = event.target as Element;
const cache = this._cache;

if (!cache.has(element)) {
cache.set(element, {
scrollTop: element.firstElementChild?.scrollTop ?? element.scrollTop,
scrollLeft: element.firstElementChild?.scrollLeft ?? element.scrollLeft,
});
}

const record = cache.get(element)!;
Object.assign(element, record);

if (element.firstElementChild) {
Object.assign(element.firstElementChild, record);
}
}

public update(config?: RootScrollControllerConfig) {
if (config) {
this.config = { ...this.config, ...config };
}

if (config?.resetListeners) {
this.removeEventListeners();
}

this.configureListeners();
}

public hostConnected() {
this.configureListeners();
}

public hostDisconnected() {
this.removeEventListeners();
}
}

export function addRootScrollHandler(
host: RootScrollControllerHost,
config?: RootScrollControllerConfig
) {
return new RootScrollController(host, config);
}
Loading

0 comments on commit 3a4d52e

Please sign in to comment.