From 2cf873b112007fa7b446aef2e5de32dfc902246c Mon Sep 17 00:00:00 2001 From: Riccardo Perra Date: Sat, 11 Nov 2023 17:54:30 +0100 Subject: [PATCH] add segmented control --- .../SegmentedControl/SegmentedControl.css.ts | 208 ++++++++++++++++++ .../SegmentedControl/SegmentedControl.tsx | 81 +++++++ packages/kit/src/index.tsx | 5 + .../src/components/ui/DemoSection.css.ts | 8 + .../src/components/ui/DemoSection.tsx | 4 + packages/playground-next/src/root.tsx | 3 + .../src/routes/segmented-control.tsx | 163 ++++++++++++++ 7 files changed, 472 insertions(+) create mode 100644 packages/kit/src/components/SegmentedControl/SegmentedControl.css.ts create mode 100644 packages/kit/src/components/SegmentedControl/SegmentedControl.tsx create mode 100644 packages/playground-next/src/routes/segmented-control.tsx diff --git a/packages/kit/src/components/SegmentedControl/SegmentedControl.css.ts b/packages/kit/src/components/SegmentedControl/SegmentedControl.css.ts new file mode 100644 index 0000000..3e61041 --- /dev/null +++ b/packages/kit/src/components/SegmentedControl/SegmentedControl.css.ts @@ -0,0 +1,208 @@ +import { createTheme, style } from "@vanilla-extract/css"; +import { themeTokens, themeVars } from "../../foundation"; +import { recipe, RecipeVariants } from "@vanilla-extract/recipes"; +import { mapSizeValue } from "../../foundation/sizes"; +import { componentStateStyles } from "@kobalte/vanilla-extract"; + +export const [segmentedFieldTheme, segmentedFieldVars] = createTheme({ + // TODO fix this + activeSegmentedBackgroundColor: themeVars.brandSecondaryAccentHover, + segmentedTextColor: themeVars.foreground, + activeSegmentedTextColor: themeVars.foreground, + segmentWrapperSolidBackground: themeVars.formAccent, + segmentWrapperBorderedColor: themeVars.formAccentBorder, + segmentWrapperRadius: themeTokens.radii.md, + segmentWrapperPadding: themeTokens.spacing["1"], + segmentHeight: "", + segmentPadding: "", + segmentRadius: themeTokens.radii.sm, + segmentFontSize: themeTokens.fontSize.sm, +}); + +const SegmentedControlSizes = { + xs: "xs", + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", +} as const; + +export const segmentedControlWrapper = recipe({ + base: [ + segmentedFieldTheme, + { + display: "inline-flex", + padding: segmentedFieldVars.segmentWrapperPadding, + height: segmentedFieldVars.segmentHeight, + position: "relative", + overflow: "visible", + cursor: "default", + textAlign: "center", + userSelect: "none", + borderRadius: segmentedFieldVars.segmentWrapperRadius, + }, + componentStateStyles({ + disabled: { + opacity: 0.5, + }, + }), + ], + variants: { + theme: { + neutral: { + vars: {}, + }, + primary: { + vars: { + [segmentedFieldVars.activeSegmentedBackgroundColor]: themeVars.brand, + [segmentedFieldVars.activeSegmentedTextColor]: themeTokens.colors.gray12, + }, + }, + }, + variant: { + solid: { + backgroundColor: segmentedFieldVars.segmentWrapperSolidBackground, + }, + bordered: { + backgroundColor: "transparent", + boxShadow: `0 0 0 2px ${segmentedFieldVars.segmentWrapperBorderedColor}`, + }, + }, + size: { + [SegmentedControlSizes.xl]: { + vars: { + [segmentedFieldVars.segmentWrapperPadding]: `calc(${themeTokens.spacing["1"]} * 1.5)`, + [segmentedFieldVars.segmentHeight]: mapSizeValue("xl"), + [segmentedFieldVars.segmentFontSize]: themeTokens.fontSize.lg, + [segmentedFieldVars.segmentPadding]: themeTokens.spacing["6"], + [segmentedFieldVars.segmentRadius]: themeTokens.radii.lg, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.xl, + }, + }, + [SegmentedControlSizes.lg]: { + vars: { + [segmentedFieldVars.segmentWrapperPadding]: themeTokens.spacing["1"], + [segmentedFieldVars.segmentHeight]: mapSizeValue("lg"), + [segmentedFieldVars.segmentFontSize]: themeTokens.fontSize.md, + [segmentedFieldVars.segmentPadding]: themeTokens.spacing["5"], + [segmentedFieldVars.segmentRadius]: themeTokens.radii.sm, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.md, + }, + }, + [SegmentedControlSizes.md]: { + vars: { + [segmentedFieldVars.segmentWrapperPadding]: themeTokens.spacing["1"], + [segmentedFieldVars.segmentHeight]: mapSizeValue("md"), + [segmentedFieldVars.segmentFontSize]: themeTokens.fontSize.md, + [segmentedFieldVars.segmentPadding]: themeTokens.spacing["4"], + [segmentedFieldVars.segmentRadius]: themeTokens.radii.sm, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.md, + }, + }, + [SegmentedControlSizes.sm]: { + vars: { + [segmentedFieldVars.segmentWrapperPadding]: themeTokens.spacing["1"], + [segmentedFieldVars.segmentHeight]: mapSizeValue("sm"), + [segmentedFieldVars.segmentFontSize]: themeTokens.fontSize.sm, + [segmentedFieldVars.segmentPadding]: themeTokens.spacing["3"], + [segmentedFieldVars.segmentRadius]: themeTokens.radii.xs, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.sm, + }, + }, + [SegmentedControlSizes.xs]: { + vars: { + [segmentedFieldVars.segmentWrapperPadding]: themeTokens.spacing["1"], + [segmentedFieldVars.segmentHeight]: mapSizeValue("xs"), + [segmentedFieldVars.segmentFontSize]: themeTokens.fontSize.xs, + [segmentedFieldVars.segmentPadding]: themeTokens.spacing["2"], + [segmentedFieldVars.segmentRadius]: themeTokens.radii.xs, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.sm, + }, + }, + }, + fluid: { + true: { + width: "100%", + vars: { + [segmentedFieldVars.segmentHeight]: "100%", + }, + }, + }, + pill: { + true: { + vars: { + [segmentedFieldVars.segmentRadius]: themeTokens.radii.full, + [segmentedFieldVars.segmentWrapperRadius]: themeTokens.radii.full, + }, + }, + }, + }, +}); + +export const list = style({ + display: "flex", + gap: themeTokens.spacing["2"], + position: "relative", + flex: 1, + flexWrap: "nowrap", + flexShrink: 0, + alignItems: "center", +}); + +export type SegmentedControlVariants = RecipeVariants; + +export const segment = style([ + { + width: "100%", + height: "100%", + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + whiteSpace: "nowrap", + flexGrow: 1, + fontSize: segmentedFieldVars.segmentFontSize, + padding: `0 ${segmentedFieldVars.segmentPadding}`, + color: segmentedFieldVars.segmentedTextColor, + opacity: 0.8, + zIndex: 1, + fontWeight: themeTokens.fontWeight.medium, + borderRadius: segmentedFieldVars.segmentRadius, + gap: themeTokens.spacing["2"], + transition: + "opacity .2s, background-color .2s, transform .2s, outline-color 150ms ease-in-out, outline-offset 150ms ease-in", + outlineColor: `transparent`, + outlineOffset: "0px", + selectors: { + "&:not(:disabled)": { + cursor: "pointer", + }, + "&[data-selected]": { + opacity: 1, + color: segmentedFieldVars.activeSegmentedTextColor, + }, + }, + ":focus-visible": { + outlineOffset: "2px", + outline: `2px solid ${themeVars.brand}`, + }, + }, + componentStateStyles({ + disabled: { + opacity: 0.2, + }, + }), +]); + +export const indicator = style([ + { + position: "absolute", + height: "100%", + transition: + "width 250ms cubic-bezier(.2, 0, 0, 1), transform 250ms cubic-bezier(.2, 0, 0, 1)", + backgroundColor: segmentedFieldVars.activeSegmentedBackgroundColor, + content: "", + boxShadow: themeTokens.boxShadow.default, + borderRadius: segmentedFieldVars.segmentRadius, + }, +]); diff --git a/packages/kit/src/components/SegmentedControl/SegmentedControl.tsx b/packages/kit/src/components/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 0000000..353746e --- /dev/null +++ b/packages/kit/src/components/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,81 @@ +import { Tabs } from "@kobalte/core"; +import * as styles from "./SegmentedControl.css"; +import { SegmentedControlVariants } from "./SegmentedControl.css"; +import { splitProps } from "solid-js"; +import { mergeClasses } from "../../utils/css"; +import { SlotProp } from "../../utils/component"; + +type TypedTabsRootProps = { + value?: T; + defaultValue?: T; + onChange?: (value: T) => void; +}; + +type SegmentedControlProps = Omit< + Tabs.TabsRootProps, + "orientation" | keyof TypedTabsRootProps +> & + TypedTabsRootProps & + SegmentedControlVariants & + SlotProp; + +type SegmentedControlSlot = "root" | "list" | "indicator"; + +export function SegmentedControl(props: SegmentedControlProps) { + const [local, others] = splitProps(props, [ + "class", + "theme", + "fluid", + "size", + "variant", + "pill", + "slotClasses", + "class", + ]); + const disabled = () => (props.disabled ? "" : undefined); + + const rootClasses = () => + mergeClasses( + local.slotClasses?.root, + local.class, + styles.segmentedControlWrapper({ + theme: local.theme ?? "neutral", + size: local.size, + variant: local.variant ?? "solid", + fluid: local.fluid, + pill: local.pill, + }), + ); + + return ( + + + {props.children} + + + + ); +} + +type SegmentedControlItemProps = Tabs.TabsTriggerProps; + +export function SegmentedControlItem(props: SegmentedControlItemProps) { + const [local, others] = splitProps(props, ["class"]); + + const classes = () => mergeClasses(styles.segment, local.class); + + return ; +} diff --git a/packages/kit/src/index.tsx b/packages/kit/src/index.tsx index 8c45056..f8d665d 100644 --- a/packages/kit/src/index.tsx +++ b/packages/kit/src/index.tsx @@ -45,6 +45,11 @@ export { Tooltip } from "./components/Tooltip/Tooltip"; export { Tabs, TabsHeader, TabsList, TabsContent } from "./components/Tabs/Tabs"; +export { + SegmentedControl, + SegmentedControlItem, +} from "./components/SegmentedControl/SegmentedControl"; + export { SvgIcon } from "./icons/SvgIcon"; export * as icons from "./icons"; diff --git a/packages/playground-next/src/components/ui/DemoSection.css.ts b/packages/playground-next/src/components/ui/DemoSection.css.ts index 837364f..4c97239 100644 --- a/packages/playground-next/src/components/ui/DemoSection.css.ts +++ b/packages/playground-next/src/components/ui/DemoSection.css.ts @@ -12,3 +12,11 @@ export const row = style({ alignItems: "center", flexWrap: "wrap", }); + +export const rowInline = style({ + display: "inline-flex", + gap: "2rem", + padding: "1rem", + alignItems: "center", + flexWrap: "wrap", +}); diff --git a/packages/playground-next/src/components/ui/DemoSection.tsx b/packages/playground-next/src/components/ui/DemoSection.tsx index 8803433..35047b6 100644 --- a/packages/playground-next/src/components/ui/DemoSection.tsx +++ b/packages/playground-next/src/components/ui/DemoSection.tsx @@ -8,3 +8,7 @@ export function DemoSection(props: ParentProps) { export function DemoSectionRow(props: ParentProps) { return
{props.children}
; } + +export function DemoSectionRowInline(props: ParentProps) { + return
{props.children}
; +} diff --git a/packages/playground-next/src/root.tsx b/packages/playground-next/src/root.tsx index 34b19a3..edb27d5 100644 --- a/packages/playground-next/src/root.tsx +++ b/packages/playground-next/src/root.tsx @@ -100,6 +100,9 @@ export default function Root() { Tabs + + Segmented Control +
diff --git a/packages/playground-next/src/routes/segmented-control.tsx b/packages/playground-next/src/routes/segmented-control.tsx new file mode 100644 index 0000000..301c532 --- /dev/null +++ b/packages/playground-next/src/routes/segmented-control.tsx @@ -0,0 +1,163 @@ +import { SegmentedControl, SegmentedControlItem } from "@codeui/kit"; +import { DemoSectionRow } from "~/components/ui/DemoSection"; +import { For } from "solid-js"; +import { ClipboardIcon } from "~/components/icons/ClipboardIcon"; + +export default function SegmentedControlDemo() { + return ( + <> +

Segmented Control

+ +

Solid style

+ + + + {size => ( + + Left + Center + Right + + )} + + + +

Bordered style

+ + + + {size => ( + + Left + Center + Right + + )} + + + +

Pill style

+ + + + Left + Center + Right + + + Left + Center + Right + + + +

Theme

+ + + + {theme => ( + + Left + Center + Right + + )} + + + +

With Icons

+ + + + {size => ( + + + + + + + + + + + + )} + + + +

Adapt to container size

+ + +
+ + + + + + + + +
+
+ +

Disabled tab

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}