From e499f33c59937bf913ade50c73f9e27bfdbcec19 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 7 Jan 2024 14:45:04 +0100 Subject: [PATCH 1/4] feat: add combobox --- .../src/components/Combobox/Combobox.css.ts | 196 +++++++++++++++ .../kit/src/components/Combobox/Combobox.tsx | 131 ++++++++++ packages/kit/src/index.tsx | 3 + .../kit/src/utils/highlight/highlight.css.ts | 7 + packages/kit/src/utils/highlight/highlight.ts | 89 +++++++ .../src/stories/Combobox.stories.tsx | 233 ++++++++++++++++++ 6 files changed, 659 insertions(+) create mode 100644 packages/kit/src/components/Combobox/Combobox.css.ts create mode 100644 packages/kit/src/components/Combobox/Combobox.tsx create mode 100644 packages/kit/src/utils/highlight/highlight.css.ts create mode 100644 packages/kit/src/utils/highlight/highlight.ts create mode 100644 packages/storybook/src/stories/Combobox.stories.tsx diff --git a/packages/kit/src/components/Combobox/Combobox.css.ts b/packages/kit/src/components/Combobox/Combobox.css.ts new file mode 100644 index 0000000..47afa80 --- /dev/null +++ b/packages/kit/src/components/Combobox/Combobox.css.ts @@ -0,0 +1,196 @@ +import { createTheme, keyframes, style } from "@vanilla-extract/css"; +import { themeTokens } from "../../foundation/themes.css"; +import { componentStateStyles } from "@kobalte/vanilla-extract"; +import { tokens } from "../../foundation/contract.css"; +import { baseFieldTheme, baseFieldVars } from "../Field/Field.css"; +import { responsiveStyle } from "../../foundation/responsive"; +import { themeVars } from "../../foundation"; + +export const [selectTheme, selectThemeVars] = 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 contentShow = keyframes({ + from: { + opacity: 0, + transform: "translateY(-10px)", + }, + to: { + opacity: 1, + transform: "translateY(0px)", + }, +}); + +const contentHide = keyframes({ + from: { + opacity: 1, + transform: "translateY(0px)", + }, + to: { + opacity: 0, + transform: "translateY(-10px)", + }, +}); + +// TODO: common popover/dropdown style +export const content = style([ + selectTheme, + { + boxShadow: selectThemeVars.contentBoxShadow, + backgroundColor: selectThemeVars.contentBackground, + borderRadius: selectThemeVars.contentRadius, + padding: selectThemeVars.contentPadding, + overflow: "auto", + zIndex: themeTokens.zIndex["50"], + listStyleType: "none", + display: "flex", + flexDirection: "column", + rowGap: themeTokens.spacing["1"], + outline: "none", + maxHeight: selectThemeVars.contentMaxHeight, + animation: `${contentHide} 250ms ease-in-out`, + border: `1px solid ${selectThemeVars.contentBorderColor}`, + }, + responsiveStyle({ + xs: { + vars: { + [selectThemeVars.contentMaxHeight]: selectThemeVars.contentMaxHeightXs, + }, + }, + sm: { + vars: { + [selectThemeVars.contentMaxHeight]: "400px", + }, + }, + }), + componentStateStyles({ + expanded: { + animation: `${contentShow} 250ms ease-in-out`, + }, + }), +]); + +export const input = style([ + { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, +]); + +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: selectThemeVars.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: selectThemeVars.itemMinHeight, + selectors: { + "&:first-child,&:last-child": { + margin: 0, + }, + }, + }, + { + ":disabled": { + opacity: selectThemeVars.itemDisabledOpacity, + }, + ":focus": { + boxShadow: "none", + outline: "none", + backgroundColor: selectThemeVars.itemHoverBackground, + color: selectThemeVars.itemHoverTextColor, + }, + ":focus-visible": { + backgroundColor: selectThemeVars.itemHoverBackground, + color: selectThemeVars.itemHoverTextColor, + }, + }, + componentStateStyles({ + highlighted: { + boxShadow: "none", + outline: "none", + backgroundColor: selectThemeVars.itemHoverBackground, + color: selectThemeVars.itemHoverTextColor, + }, + selected: { + not: { + paddingRight: `calc(${themeTokens.spacing["3"]} + ${selectThemeVars.indicatorSize} + ${themeTokens.spacing["2"]})`, + }, + }, + disabled: { + opacity: selectThemeVars.itemDisabledOpacity, + not: { + ":hover": {}, + }, + }, + }), +]); + +export const field = style([ + baseFieldTheme, + { + display: "flex", + flexDirection: "column", + gap: themeTokens.spacing["3"], + flex: 1, + height: "100%", + }, +]); + +// TODO: Unify with Select? +export const comboboxField = style([ + { + display: "inline-flex", + alignItems: "center", + justifyContent: "space-between", + paddingRight: themeTokens.spacing["3"], + paddingLeft: themeTokens.spacing["3"], + paddingTop: 0, + paddingBottom: 0, + outline: "none", + width: "100%", + fontSize: baseFieldVars.fontSize, + }, +]); + +export const comboboxInput = style({ + color: themeVars.foreground, + appearance: "none", + background: "transparent", + outline: "none", + display: "inline-flex", + minWidth: 0, + flex: 1, +}); + +export const itemIndicator = style({ + marginLeft: "auto", + height: selectThemeVars.indicatorSize, + width: selectThemeVars.indicatorSize, + flexShrink: 0, +}); diff --git a/packages/kit/src/components/Combobox/Combobox.tsx b/packages/kit/src/components/Combobox/Combobox.tsx new file mode 100644 index 0000000..51713ec --- /dev/null +++ b/packages/kit/src/components/Combobox/Combobox.tsx @@ -0,0 +1,131 @@ +import { Combobox as KCombobox } from "@kobalte/core"; +import { Accessor, JSX, JSXElement, Show, createSignal, splitProps } from "solid-js"; +import { CheckIcon } from "../../icons/CheckIcon"; +import { SelectorIcon } from "../../icons/SelectorIcon"; +import { mergeClasses } from "../../utils/css"; +import { BaseFieldProps, createBaseFieldProps } from "../Field/createBaseFieldProps"; +import { + createFieldErrorMessageProps, + FieldWithErrorMessageSupport, +} from "../Field/FieldError/createFieldErrorMessageProps"; +import { createFieldLabelProps } from "../Field/FieldLabel/createFieldLabelProps"; +import { createFieldMessageProps } from "../Field/FieldMessage/createFieldMessageProps"; +import * as styles from "./Combobox.css"; +import { highlight } from "../../utils/highlight/highlight"; + +void highlight; + +export type ComboboxProps = KCombobox.ComboboxRootProps< + Option, + OptGroup +> & + BaseFieldProps & { + "aria-label": string; + placeholder?: string; + description?: string; + label?: JSX.Element; + } & FieldWithErrorMessageSupport & { + itemLabel?: (item: Option) => JSXElement; + valueComponent?: (state: Accessor