Skip to content
This repository was archived by the owner on Jan 15, 2024. It is now read-only.

Feat #16 dropdown compoennt #28

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ package-lock.json
yarn-error.log
.env
.DS_Store
docs/
docs/
294 changes: 294 additions & 0 deletions lib/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import Nullstack, {
NullstackClientContext,
NullstackFunctionalComponent,
NullstackNode,
} from "nullstack";

import { computePosition, flip, offset, type Placement, shift } from "@floating-ui/dom";

import tc from "../tc";
import type { BaseProps } from "../types";

export const baseDropdown = {
slots: {
wrapper: "",
container:
"hidden absolute z-10 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
item: {
base: "text-gray-700 block px-4 py-2 text-sm",
variants: {
type: {
option: "hover:bg-gray-200",
},
active: {
true: "bg-gray-400",
},
},
},
},
};

interface DropdownProps extends BaseProps {
children: NullstackNode[];
offset?: number;
placement?: Placement;
}

interface DropdownContainerProps extends BaseProps {
Dropdown: Dropdown;
}

type CustonChildren = NullstackNode & {
attributes: {
Dropdown: Dropdown;
};
};

type CustonChildrenTarget = NullstackNode & {
attributes: {
Dropdown: Dropdown;
onclick: () => void;
ref: { object: Dropdown; property: "_targetRef" };
};
};

type CustonChildrenItem = NullstackNode & {
attributes: {
Dropdown: Dropdown;
active?: boolean;
index?: string | number;
};
};

type DropdownTargetProps = DropdownContainerProps;

interface DropdownItemProps extends BaseProps {
Dropdown: Dropdown;
href?: string;
index: number;
onclick?: (index: number) => void;
title?: string;
type?: "option" | "none";
}
class Dropdown extends Nullstack {
visible: boolean;
_targetRef: HTMLElement;
_dropdownRef: HTMLElement;
_currentElement: HTMLElement;
_currentElementIndex = null;

static Target: NullstackFunctionalComponent<DropdownTargetProps> = ({
Dropdown,
children,
}: NullstackClientContext<DropdownTargetProps>) => {
const child = children.map((childItem: CustonChildrenTarget) => {
childItem.attributes.ref = { object: Dropdown, property: "_targetRef" };
childItem.attributes.onclick = Dropdown.visible ? Dropdown._hide : Dropdown._show;

return childItem;
});

return child;
};

static Container: NullstackFunctionalComponent<DropdownContainerProps> = ({
Dropdown,
children,
class: klass,
theme,
}: NullstackClientContext<DropdownContainerProps>) => {
const { container } = tc(baseDropdown, theme?.dropdown)();
const child = children.map((item: CustonChildrenItem, index) => {
item.attributes.Dropdown = Dropdown;
item.attributes.index = `${index}`;
return item;
});
return (
<div
ref={Dropdown._dropdownRef}
id="dropdown-container"
role="presentation"
class={container({ class: klass })}
>
{child}
</div>
);
};

static Item: NullstackFunctionalComponent<DropdownItemProps> = ({
Dropdown,
children,
class: klass,
href,
index,
onclick,
theme,
title,
type = "option",
}: NullstackClientContext<DropdownItemProps>) => {
const { item } = tc(baseDropdown, theme?.dropdown)();
return (
<element
tag={type == "option" ? "a" : "div"}
role="none"
aria-orientation="vertical"
title={title}
href={type == "option" ? href : undefined}
aria-labelledby="menu-button"
tabindex="0"
class={item({
class: `${klass} dropdown-item`,
type,
})}
onclick={[
() => Dropdown._selected(index),
() => {
if (onclick) {
onclick(index);
}
},
]}
>
{children}
</element>
);
};

_elementsRef() {
return this._dropdownRef.querySelectorAll<HTMLTableRowElement>(".dropdown-item");
}

_show() {
this._dropdownRef.style.display = "block";
this.updatePosition();
this.visible = true;
}

_hide() {
this._deactiveElement(this._currentElement);
this._currentElement = null;
this._currentElementIndex = null;
this._dropdownRef.style.display = "none";
this.visible = false;
}

_selected(index: number) {
this._updateCurrentElement(index);
this._hide();
}

_outsideClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (event?.target) {
if (!this._dropdownRef.contains(target) && !this._targetRef.contains(target)) {
this._hide();
}
}
}

_activeClass() {
// TODO: implement dynamic change for theme oevrlay
return baseDropdown.slots.item.variants.active.true;
}

_activeElement(element?: HTMLElement) {
if (element) {
element.classList.add(this._activeClass());
}
}

_deactiveElement(element?: HTMLElement) {
if (element) {
element.classList.remove(this._activeClass());
}
}

_onArrowDown() {
let index = this._currentElementIndex;
if (index === null || index + 1 >= this._elementsRef().length) {
index = 0;
} else {
index = index + 1;
}
this._updateCurrentElement(index);
}

_onArrowUp() {
let index = this._currentElementIndex;
if (index == 0) {
index = this._elementsRef().length - 1;
} else {
index = index - 1;
}
this._updateCurrentElement(index);
}

_updateCurrentElement(index: number) {
this._deactiveElement(this._currentElement);
this._currentElement = this._elementsRef()[index];
this._currentElementIndex = index;
}
_onKeyDown(event: KeyboardEvent) {
const key = event.key; // Preferred way to get the key value
if (this.visible) {
switch (key) {
case "Enter":
event.preventDefault();
if (this._currentElement) {
this._activeElement(this._currentElement);
if (this._currentElement?.click) {
this._currentElement.click();
}
}
break;
case "ArrowDown":
event.preventDefault();
this._onArrowDown();
break;
case "ArrowUp":
event.preventDefault();
this._onArrowUp();
break;
}
this._activeElement(this._currentElement);
}
}
updatePosition(context?: NullstackClientContext<DropdownProps>) {
computePosition(this._targetRef, this._dropdownRef, {
placement: context.placement || "top",
middleware: [offset(context.offset || 8), flip(), shift()],
}).then(({ x, y }) => {
Object.assign(this._targetRef.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
hydrate() {
document.addEventListener("click", this._outsideClick);
document.addEventListener("keydown", this._onKeyDown);
}

terminate() {
document.removeEventListener("click", this._outsideClick);
document.removeEventListener("keydown", this._onKeyDown);
this._currentElementIndex = null;
this._currentElement = null;
}

render({ children, class: klass, theme }: NullstackClientContext<DropdownProps>) {
children = children?.map((child: CustonChildren) => {
child.attributes.Dropdown = this;
return child;
});

const { wrapper } = tc(baseDropdown, theme?.dropdown)();
return (
<div role="menu" id="menu" class={wrapper({ class: klass })}>
{children}
</div>
);
}
}

export default Dropdown;
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { default as Button } from "./components/Button";
export { default as CopyButton } from "./components/CopyButton";
export { default as ButtonGroup } from "./components/ButtonGroup";
export { default as Divider } from "./components/Divider";
export { default as Dropdown } from "./components/Dropdown";
export { default as Modal } from "./components/Modal";
export { default as Tabs } from "./components/Tabs";
export { default as Table } from "./components/Table";
Expand Down
1 change: 1 addition & 0 deletions lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { baseInput as input } from "./components/forms/Input";
export { baseTitle as title } from "./components/Title";
export { baseToggle as toggle } from "./components/forms/Toggle";
export { baseTooltip as tooltip } from "./components/Tooltip";
export { baseDropdown as dropdown } from "./components/Dropdown";
export { basePopover as popover } from "./components/Popover";
export { basePagination as pagination } from "./components/Pagination";
export { baseNotifications as notifications } from "./components/Notifications";
65 changes: 65 additions & 0 deletions src/pages/components/Dropdown/Dropdown.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { theme } from "nullwind";
import Preview from "./Preview.jsx";

import { Meta, Demo, ThemePreview } from "~/components";

<Meta title="Dropdown" description="Dropdown component render a toggleable list" />

# Divider

Dropdown component renders a toggleable , contextual overlay for diplay a list of itens .

You can add eveent click for every item withc `onclick` function

Also you can set `href` props for linkable element

## Usage

<Demo
component={Preview}
template={(props) => `
import Nullstack from "nullstack";

import { Button, Dropdown } from "nullwind";

class Preview extends Nullstack {

render() {
return (
<Dropdown>
<Dropdown.Target>
<Button class="bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
<spam>Teste</spam>
<svg
class="-mr-1 h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</Button>
</Dropdown.Target>
<Dropdown.Container>
<Dropdown.Item href="https://google.com">
<p href="https://google.com">Text</p>
</Dropdown.Item>
<Dropdown.Item>Text 2</Dropdown.Item>
<Dropdown.Item onclick={(index) => console.log("get index for element: "+ index)}>click event</Dropdown.Item>
<Dropdown.Item type="none">
<Button color="danger" type="submit" role="menuitem">Sign out</Button>
</Dropdown.Item>
</Dropdown.Container>
</Dropdown>
);
}
}

export default Preview;
`}/>


Loading