Skip to content

Commit

Permalink
Add Listbox and ListboxItem components (#53)
Browse files Browse the repository at this point in the history
* Add Listbox and ListboxItem components

Implemented Listbox and ListboxItem components, along with their styling. These components provide interactive lists for user selection. Also added corresponding storybook stories and tests. Dependency packages for solid-virtual were installed to support virtualization within the listbox.

* Add new features to Listbox component

This commit introduces new functionalities to the Listbox component. Properties like 'size', 'theme', and 'bordered' have been added providing more customization options. Additionally, a 'toPx' function has been implemented to simplify CSS value conversions. This function is used for more precise size values for the Listbox items. Storybook stories have also been updated to accommodate these changes.

* Improve and extend Listbox components

This commit brings enhancements to the Listbox component by introducing the 'shouldFocusOnHover' prop and refining the typing of VirtualizedListboxProps. The modifications promote better customization of the list boxes and lay groundwork for more sophisticated property handling mechanisms. Furthermore, the diffs include the addition of the 'splitProps' function to separate 'options' and 'itemLabel', thereby refining local component settings.

* Remove ts-expect-error comments from Storybook stories

This commit removes ts-expect-error comments in multiple Storybook stories, including TextArea, TextField, Select, and Listbox. The removal suggests that previously existing typescript errors have been resolved. Also, it includes type correction in Listbox story where options are casted to string array.

* Update Storybook scripts and resolve TypeScript errors

This commit updates the scripts in the Storybook package.json to use simpler calls and removes ts-expect-error comments in multiple Storybook stories, thereby acknowledging the resolution of TypeScript errors. The build command in Netlify is also adjusted. Further, it includes dependency library updates in the lock file.

* Downgrade TypeScript version in Storybook package

This commit downgrades the TypeScript version in the Storybook package from ^5.0.2 to ^4.9.5. The change also updates the TypeScript

* Revert to previous package versions in pnpm-lock.yaml

This commit downgrades several packages within the pnpm-lock.yaml file to their preceding versions. This was necessary to resolve

* Enhanced package dependencies and adjusted TypeScript version

Applied a patch to the @storybook/manager-api package for issue resolution, and upgraded TypeScript from 4.9.5 to 5.0.4 across various sections of the code. This update ensures compatibility and leverages the latest TypeScript enhancements.

* Adjust Listbox stories

* Adjust Listbox stories

* Create metal-doors-mate.md
  • Loading branch information
riccardoperra authored Nov 26, 2023
1 parent 7883a91 commit fea16ba
Show file tree
Hide file tree
Showing 23 changed files with 666 additions and 86 deletions.
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

0 comments on commit fea16ba

Please sign in to comment.