Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Listbox and ListboxItem components #53

Merged
merged 11 commits into from
Nov 26, 2023
6 changes: 6 additions & 0 deletions .changeset/metal-doors-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@codeui/kit": patch
"@codeui/storybook-playground": patch
---

Add Listbox and ListboxItem components
2 changes: 1 addition & 1 deletion netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
[build]
base = "/"
publish = "packages/storybook/storybook-static"
command = "pnpm --filter=@codeui/storybook-playground build-storybook"
command = "pnpm --filter=@codeui/storybook-playground build"
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,10 @@
"packageManager": "pnpm@8.2.0",
"dependencies": {
"@changesets/changelog-git": "^0.1.14"
},
"pnpm": {
"patchedDependencies": {
"@storybook/manager-api@7.5.3": "patches/@storybook__manager-api@7.5.3.patch"
}
}
}
2 changes: 2 additions & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
"@radix-ui/colors": "^0.1.8",
"@solid-primitives/pagination": "^0.2.5",
"@solid-primitives/scheduled": "^1.4.1",
"@tanstack/solid-virtual": "^3.0.0-beta.6",
"@tanstack/virtual-core": "^3.0.0-alpha.1",
"@vanilla-extract/css": "^1.11.0",
"@vanilla-extract/dynamic": "^2.0.3",
"@vanilla-extract/recipes": "^0.4.0",
Expand Down
162 changes: 162 additions & 0 deletions packages/kit/src/components/Listbox/Listbox.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { createTheme, style } from "@vanilla-extract/css";
import { tokens } from "../../foundation/contract.css";
import { themeTokens, themeVars } from "../../foundation";
import { componentStateStyles } from "@kobalte/vanilla-extract";
import { LISTBOX_ITEM_SIZE } from "./sizes";
import { toPx } from "../../utils/css";

export const [listTheme, listThemeVars] = createTheme({
contentBackground: tokens.dropdownBackground,
contentRadius: themeTokens.radii.lg,
contentBoxShadow: tokens.dropdownBoxShadow,
contentPadding: themeTokens.spacing["2"],
contentBorderColor: tokens.dropdownBorder,
contentMaxHeight: "400px",
contentMaxHeightXs: "270px",
separator: tokens.dropdownBorder,
itemMinHeight: "2.60rem",
itemTextColor: tokens.dropdownItemTextColor,
itemHoverBackground: tokens.dropdownItemHoverBackground,
itemHoverTextColor: tokens.dropdownItemHoverTextColor,
itemDisabledOpacity: ".4",
indicatorSize: "20px",
});

const ButtonSizes = {
xs: "xs",
sm: "sm",
md: "md",
lg: "lg",
xl: "xl",
} as const;

export const list = style([
listTheme,
{
borderRadius: themeTokens.radii.sm,
selectors: {
"&[data-bordered]": {
padding: themeTokens.spacing["2"],
border: `1px solid ${themeVars.separator}`,
},
"&[data-theme=primary]": {
vars: {
[listThemeVars.itemTextColor]: tokens.dropdownItemTextColor,
[listThemeVars.itemHoverBackground]: tokens.brandAccentHover,
[listThemeVars.itemHoverTextColor]: tokens.dropdownItemHoverTextColor,
},
},
"&[data-theme=neutral]": {
vars: {
[listThemeVars.itemTextColor]: tokens.dropdownItemTextColor,
[listThemeVars.itemHoverBackground]: tokens.dropdownItemHoverBackground,
[listThemeVars.itemHoverTextColor]: tokens.dropdownItemHoverTextColor,
},
},
},
},
]);

/**
* TODO: same as select!
*/
export const item = style([
{
textAlign: "left",
justifyContent: "space-between",
border: 0,
padding: `${themeTokens.spacing["2"]} ${themeTokens.spacing["3"]}`,
borderRadius: themeTokens.radii.sm,
background: "transparent",
color: listThemeVars.itemTextColor,
userSelect: "none",
display: "flex",
alignItems: "center",
outline: "none",
fontWeight: themeTokens.fontWeight.normal,
transition: "opacity .2s, background-color .2s, transform .2s",
gap: themeTokens.spacing["2"],
margin: `${themeTokens.spacing["1"]} 0`,
minHeight: listThemeVars.itemMinHeight,
selectors: {
"&:first-child,&:last-child": {
margin: 0,
},
},
},
{
selectors: {
[`&[data-size=${ButtonSizes.xs}]`]: {
height: toPx(LISTBOX_ITEM_SIZE.xs),
fontSize: themeTokens.fontSize.sm,
borderRadius: themeTokens.radii.xs,
minHeight: 0,
},
[`&[data-size=${ButtonSizes.sm}]`]: {
height: toPx(LISTBOX_ITEM_SIZE.sm),
fontSize: themeTokens.fontSize.md,
minHeight: 0,
},
[`&[data-size=${ButtonSizes.md}]`]: {
height: toPx(LISTBOX_ITEM_SIZE.md),
fontSize: themeTokens.fontSize.md,
},
},
},
{
":disabled": {
opacity: listThemeVars.itemDisabledOpacity,
},
":focus": {
boxShadow: "none",
outline: "none",
backgroundColor: listThemeVars.itemHoverBackground,
color: listThemeVars.itemHoverTextColor,
},
":focus-visible": {
backgroundColor: listThemeVars.itemHoverBackground,
color: listThemeVars.itemHoverTextColor,
},
},
componentStateStyles({
highlighted: {
boxShadow: "none",
outline: "none",
backgroundColor: listThemeVars.itemHoverBackground,
color: listThemeVars.itemHoverTextColor,
},
disabled: {
opacity: listThemeVars.itemDisabledOpacity,
not: {
":hover": {},
},
},
}),
]);

export const itemIndicator = style({
marginLeft: "auto",
height: listThemeVars.indicatorSize,
width: listThemeVars.indicatorSize,
strokeDashoffset: 32,
selectors: {
[`${item}[data-selected] &`]: {
strokeDashoffset: 0,
},
[`&[data-size=${ButtonSizes.xs}]`]: {
vars: {
[listThemeVars.indicatorSize]: "14px",
},
},
[`&[data-size=${ButtonSizes.sm}]`]: {
vars: {
[listThemeVars.indicatorSize]: "18px",
},
},
[`&[data-size=${ButtonSizes.md}]`]: {
vars: {
[listThemeVars.indicatorSize]: "20px",
},
},
},
});
57 changes: 57 additions & 0 deletions packages/kit/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { JSXElement, splitProps } from "solid-js";
import { Listbox as KListbox } from "@kobalte/core";
import { CheckIcon } from "../../icons";
import { mergeClasses } from "../../utils/css";
import * as styles from "./Listbox.css";

export type ListboxProps<Option, OptGroup> = Omit<
KListbox.ListboxRootProps<Option, OptGroup>,
"renderItem"
> & {
size?: "xs" | "sm" | "md";
theme?: "primary" | "neutral";
itemLabel?: (item: Option) => JSXElement;
bordered?: boolean;
};

export function Listbox<Option, OptGroup = never>(props: ListboxProps<Option, OptGroup>) {
const [local, others] = splitProps(props, [
"class",
"size",
"itemLabel",
"bordered",
"theme",
]);

return (
<KListbox.Root
data-bordered={local.bordered ? "" : undefined}
data-theme={local.theme ?? "neutral"}
class={mergeClasses(styles.list)}
shouldFocusOnHover
renderItem={node => (
<ListboxItem itemLabel={local.itemLabel} size={local.size} item={node} />
)}
{...others}
/>
);
}

export function ListboxItem<T>(
props: KListbox.ListboxItemProps & {
size?: "xs" | "sm" | "md";
itemLabel?: (item: T) => JSXElement;
},
) {
const [local, others] = splitProps(props, ["size", "itemLabel"]);
return (
<KListbox.Item data-size={props.size ?? undefined} class={styles.item} {...others}>
<KListbox.ItemLabel>
{local.itemLabel ? local.itemLabel(others.item.rawValue) : others.item.rawValue}
</KListbox.ItemLabel>
<KListbox.ItemIndicator forceMount>
<CheckIcon data-size={props.size ?? undefined} class={styles.itemIndicator} />
</KListbox.ItemIndicator>
</KListbox.Item>
);
}
111 changes: 111 additions & 0 deletions packages/kit/src/components/Listbox/VirtualizedListbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { createVirtualizer } from "@tanstack/solid-virtual";
import { Listbox, ListboxItem, ListboxProps } from "./Listbox";
import { For, splitProps } from "solid-js";
import { LISTBOX_ITEM_SIZE } from "./sizes";

type VirtualizedListboxProps<OptGroup> = Omit<
ListboxProps<Item, OptGroup>,
| "virtualized"
| "children"
| "options"
| "optionValue"
| "optionDisabled"
| "optionLabel"
| "optionTextValue"
| "scrollToItem"
> & {
options: Item[];
virtualizerOptions?: {
estimateSize?: (index: number) => number;
enableSmoothScroll?: false;
overscan?: number;
};
};

interface Item {
value: string;
label: string;
disabled?: boolean;
}

export function VirtualizedListbox<OptGroup = never>(
props: VirtualizedListboxProps<OptGroup>,
) {
let listboxRef: HTMLUListElement | undefined;

const virtualizer = createVirtualizer<HTMLUListElement | undefined, Item>({
get count() {
return props.options.length;
},
get enableSmoothScroll() {
return props.virtualizerOptions?.enableSmoothScroll ?? false;
},
get overscan() {
return props.virtualizerOptions?.overscan ?? 5;
},
getScrollElement: () => listboxRef,
estimateSize: (index: number) =>
// TODO: fix that size
props.virtualizerOptions?.estimateSize?.(index) ??
LISTBOX_ITEM_SIZE[props.size ?? "md"],
// TODO: why error?
// @ts-ignore
getItemKey: (index: number) => {
return props.options[index].value;
},
});

const [local, others] = splitProps(props, ["options", "itemLabel"]);

return (
// TODO fix type
<Listbox<any>
options={local.options}
optionValue="value"
optionTextValue="label"
optionDisabled="disabled"
ref={listboxRef}
scrollToItem={key =>
virtualizer.scrollToIndex(props.options.findIndex(option => option.value === key))
}
virtualized
{...others}
>
{items => (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<For each={virtualizer.getVirtualItems()}>
{virtualRow => {
// TODO what if key is not string?
const item = items().getItem(virtualRow.key as string);
if (item) {
return (
<ListboxItem<Item>
size={props.size}
item={item}
itemLabel={item => {
return local.itemLabel ? local.itemLabel(item) : item.label;
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
/>
);
}
}}
</For>
</div>
)}
</Listbox>
);
}
7 changes: 7 additions & 0 deletions packages/kit/src/components/Listbox/sizes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const LISTBOX_ITEM_SIZE: Record<ListboxItemSizeKey, number> = {
xs: 30,
sm: 36,
md: 40,
};

export type ListboxItemSizeKey = "xs" | "sm" | "md";
1 change: 0 additions & 1 deletion packages/kit/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Tabs as KTabs } from "@kobalte/core";
import { mergeClasses } from "../../utils/css";
import * as styles from "./Tabs.css";
import { createContext, Show, splitProps, useContext } from "solid-js";
import { tabsRoot } from "./Tabs.css";

type TabsProps = KTabs.TabsRootProps & {
theme?: "inline" | "default";
Expand Down
Loading
Loading