diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 2953389..7e042e6 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,5 +1,5 @@ import type { Preview, ReactRenderer } from "@storybook/react"; -import { withThemeByDataAttribute } from "@storybook/addon-themes"; +import { withThemeByClassName } from "@storybook/addon-themes"; import "../src/tailwind.css"; @@ -13,13 +13,12 @@ const preview: Preview = { }, }, decorators: [ - withThemeByDataAttribute({ + withThemeByClassName({ themes: { light: "", - dark: "dark", + dark: "ink-dark", }, - defaultTheme: "light", - attributeName: "data-theme", + defaultTheme: "ink-light", }), ], }; diff --git a/src/components/SegmentedControl/SegmentedControl.stories.tsx b/src/components/SegmentedControl/SegmentedControl.stories.tsx new file mode 100644 index 0000000..beaaa0b --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { SegmentedControl, SegmentedControlProps } from "./SegmentedControl"; + +const meta: Meta> = { + title: "Example/SegmentedControl", + component: SegmentedControl, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onOptionChange: fn(), + options: [ + { + label: "First", + value: "first", + selectedByDefault: true, + }, + { + label: "Second", + value: "second", + }, + { + label: "Third", + value: "third", + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Simple: Story = { + args: {}, +}; + +export const DisplayOnBlack: Story = { + args: { displayOn: "black" }, +}; diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 0000000..e773011 --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { classNames, variantClassNames } from "../../util/classes"; +import { DisplayOnProps } from "../../util/theme"; + +export interface SegmentedControlProps + extends DisplayOnProps { + options: SegmentedControlOption[]; + onOptionChange: (option: SegmentedControlOption, index: number) => void; +} + +export interface SegmentedControlOption { + label: React.ReactNode; + value: T; + selectedByDefault?: boolean; +} + +export const SegmentedControl = ({ + options, + onOptionChange, + displayOn = "auto", +}: SegmentedControlProps) => { + const itemsRef = useRef>([]); + const [selectedOption, setSelectedOption] = useState( + options.find((opt) => opt.selectedByDefault)?.value ?? null + ); + const selectedIndex = useMemo( + () => options.findIndex((opt) => opt.value === selectedOption), + [options, selectedOption] + ); + + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + + const { left, width } = useMemo(() => { + // We need to wait for the component to be mounted before we can get the + // selected element's offsetLeft and offsetWidth. + if (!isMounted) { + return { left: 0, width: 0 }; + } + const selectedElement = itemsRef.current[selectedIndex]; + return { + left: selectedElement?.offsetLeft || 0, + width: selectedElement?.offsetWidth || 0, + }; + }, [itemsRef, selectedIndex, isMounted]); + + return ( +
+ {isMounted && selectedOption && ( +
+
+
+ )} +
+ {options.map((option, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/SegmentedControl/index.ts b/src/components/SegmentedControl/index.ts new file mode 100644 index 0000000..59fabe6 --- /dev/null +++ b/src/components/SegmentedControl/index.ts @@ -0,0 +1 @@ +export * from "./SegmentedControl"; diff --git a/src/styles/Colors.stories.tsx b/src/styles/Colors.stories.tsx new file mode 100644 index 0000000..6ec2d9b --- /dev/null +++ b/src/styles/Colors.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { classNames } from "../util/classes"; + +function Colors() { + const colors = [ + "ink-bg-primary ink-text-text-on-primary", + "ink-bg-secondary ink-text-text-on-secondary", + "ink-bg-background-container", + "ink-bg-background-light-0", + "ink-bg-background-light-50", + "ink-bg-background-light", + "ink-bg-status-success-bg ink-text-status-success", + "ink-bg-status-error-bg ink-text-status-error", + "ink-bg-status-alert-bg ink-text-status-alert", + ]; + return ( +
+ {colors.map((color) => ( +
+ {color} +
+ ))} +
+ ); +} + +const meta: Meta = { + title: "Example/Colors", + component: Colors, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Simple: Story = { + args: {}, +}; diff --git a/src/styles/colors.css b/src/styles/colors.css index 274db53..85f30d1 100644 --- a/src/styles/colors.css +++ b/src/styles/colors.css @@ -3,11 +3,11 @@ --ink-background-container: color-mix( in srgb, var(--ink-background-dark), - transparent 94% + transparent 6% ); --ink-background-light: rgb(255, 255, 255); --ink-background-light-50: rgb(255, 255, 255); - --ink-background-light-0: rgb(255, 255, 0); + --ink-background-light-0: rgb(255, 255, 255); --ink-primary: rgb(113, 50, 245); --ink-primary-hover: color-mix(in srgb, var(--ink-primary), transparent 10%); diff --git a/src/util/theme.ts b/src/util/theme.ts new file mode 100644 index 0000000..a8e7a7e --- /dev/null +++ b/src/util/theme.ts @@ -0,0 +1,7 @@ +export interface DisplayOnProps { + /** + * This prop is used to override the default display. For instance, if it is always displayed in a dark container, use `displayOn="black"`. + * The default value is `auto`, which means the component will adapt based on the theme. + */ + displayOn?: "auto" | "white" | "black"; +} diff --git a/tailwind.config.mjs b/tailwind.config.mjs index 436781a..59eecff 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -17,7 +17,7 @@ export const spacing = { const config = { content: ["./src/**/*.{js,ts,jsx,tsx}"], - darkMode: ["class", '[data-mode="dark"]'], + darkMode: "selector", prefix: "ink-", theme: { gap: spacing,