Skip to content

Commit

Permalink
Add Listbox and ListboxItem components
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
riccardoperra committed Nov 25, 2023
1 parent 7883a91 commit 9eb3b17
Show file tree
Hide file tree
Showing 9 changed files with 471 additions and 51 deletions.
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
142 changes: 142 additions & 0 deletions packages/kit/src/components/Listbox/Listbox.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createTheme, style } from "@vanilla-extract/css";
import { tokens } from "../../foundation/contract.css";
import { themeTokens } from "../../foundation";
import { componentStateStyles } from "@kobalte/vanilla-extract";

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 sizesCss = {
xs: "30px",
sm: "36px",
md: "40px",
lg: "48px",
xl: "56px",
};

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

export const list = style([listTheme]);

/**
* 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: "30px",
fontSize: themeTokens.fontSize.sm,
minHeight: "1.25rem",
},
[`&[data-size=${ButtonSizes.sm}]`]: {
height: "36px",
fontSize: themeTokens.fontSize.md,
minHeight: "1.25rem",
},
[`&[data-size=${ButtonSizes.md}]`]: {
height: "42px",
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",
},
},
},
});
43 changes: 43 additions & 0 deletions packages/kit/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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";
};

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

return (
<KListbox.Root
class={mergeClasses(styles.list)}
renderItem={node => <ListboxItem size={props.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>
);
}
99 changes: 99 additions & 0 deletions packages/kit/src/components/Listbox/VirtualizedListbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createVirtualizer } from "@tanstack/solid-virtual";
import { Listbox, ListboxItem, ListboxProps } from "./Listbox";
import { For } from "solid-js";

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

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

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

const virtualizer = createVirtualizer<HTMLUListElement | undefined, Option>({
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) ?? 42,
// TODO: why error?
// @ts-ignore
getItemKey: (index: number) => {
return props.options[index].value;
},
});

return (
<Listbox
options={props.options}
optionValue="value"
optionTextValue="label"
optionDisabled="disabled"
ref={listboxRef}
scrollToItem={key =>
virtualizer.scrollToIndex(props.options.findIndex(option => option.value === key))
}
virtualized
style={{ height: "200px", width: "100%", overflow: "auto" }}
>
{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 item.label;
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
/>
);
}
}}
</For>
</div>
)}
</Listbox>
);
}
21 changes: 15 additions & 6 deletions packages/kit/src/icons/CheckIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ import { SvgIcon, SvgIconProps } from "./SvgIcon";

export function CheckIcon(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 20 20" fill="currentColor" {...props}>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
<SvgIcon
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="32"
stroke-dashoffset="0"
{...props}
>
<path style="transition: stroke-dashoffset 200ms ease 0s;" d="M20 6 9 17l-5-5" />
</SvgIcon>
);
}
3 changes: 3 additions & 0 deletions packages/kit/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export type { SegmentedControlProps } from "./components/SegmentedControl/Segmen
export { SegmentedControlItem } from "./components/SegmentedControl/SegmentedControlItem";
export type { SegmentedControlItemProps } from "./components/SegmentedControl/SegmentedControlItem";

export { Listbox, ListboxItem } from "./components/Listbox/Listbox";
export { VirtualizedListbox } from "./components/Listbox/VirtualizedListbox";

export { SvgIcon } from "./icons/SvgIcon";
export * as icons from "./icons";

Expand Down
1 change: 1 addition & 0 deletions packages/storybook/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const config: StorybookConfig = {
"@codeui/kit",
"@kobalte/core",
"@kobalte/utils",
"@tanstack/solid-virtual",
],
},
});
Expand Down
Loading

0 comments on commit 9eb3b17

Please sign in to comment.