Skip to content

Commit

Permalink
add segmented control
Browse files Browse the repository at this point in the history
  • Loading branch information
riccardoperra committed Nov 11, 2023
1 parent 51af634 commit 2cf873b
Show file tree
Hide file tree
Showing 7 changed files with 472 additions and 0 deletions.
208 changes: 208 additions & 0 deletions packages/kit/src/components/SegmentedControl/SegmentedControl.css.ts
Original file line number Diff line number Diff line change
@@ -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<typeof segmentedControlWrapper>;

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,
},
]);
81 changes: 81 additions & 0 deletions packages/kit/src/components/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
};

type SegmentedControlProps = Omit<
Tabs.TabsRootProps,
"orientation" | keyof TypedTabsRootProps<string>
> &
TypedTabsRootProps<string> &
SegmentedControlVariants &
SlotProp<SegmentedControlSlot>;

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 (
<Tabs.Root
data-cui={"segmentedControl"}
data-disabled={disabled()}
activationMode={"manual"}
orientation={"horizontal"}
class={rootClasses()}
{...props}
>
<Tabs.List
data-cui={"segmentedControlList"}
class={mergeClasses(styles.list, local.slotClasses?.list)}
>
{props.children}
<Tabs.Indicator
data-cui={"segmentedControlIndicator"}
class={mergeClasses(styles.indicator, local.slotClasses?.indicator)}
/>
</Tabs.List>
</Tabs.Root>
);
}

type SegmentedControlItemProps = Tabs.TabsTriggerProps;

export function SegmentedControlItem<T>(props: SegmentedControlItemProps) {
const [local, others] = splitProps(props, ["class"]);

const classes = () => mergeClasses(styles.segment, local.class);

return <Tabs.Trigger data-cui={"segmentedControlItem"} class={classes()} {...others} />;
}
5 changes: 5 additions & 0 deletions packages/kit/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
8 changes: 8 additions & 0 deletions packages/playground-next/src/components/ui/DemoSection.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
4 changes: 4 additions & 0 deletions packages/playground-next/src/components/ui/DemoSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ export function DemoSection(props: ParentProps) {
export function DemoSectionRow(props: ParentProps) {
return <div class={row}>{props.children}</div>;
}

export function DemoSectionRowInline(props: ParentProps) {
return <div class={row}>{props.children}</div>;
}
3 changes: 3 additions & 0 deletions packages/playground-next/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export default function Root() {
<SidebarItem>
<A href="/tabs">Tabs</A>
</SidebarItem>
<SidebarItem>
<A href="/segmented-control">Segmented Control</A>
</SidebarItem>
</Sidebar>
<div class={styles.layoutContent}>
<Routes>
Expand Down
Loading

0 comments on commit 2cf873b

Please sign in to comment.