From 795ea69e4a957f797211f076da28cdad026ef6cc Mon Sep 17 00:00:00 2001 From: sjoerdbeentjes <11621275+sjoerdbeentjes@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:39:28 +0200 Subject: [PATCH 01/17] feat(contracts): add contracts for 38 new components Adds a @surfnet/curve-contracts entry for every component in this batch (description-only where there's no variant/size/orientation axis), so both frameworks have a shared source of truth for names and docs before vendoring. --- packages/contracts/src/accordion.ts | 8 +++++ packages/contracts/src/alert-dialog.ts | 8 +++++ packages/contracts/src/alert.ts | 19 ++++++++++ packages/contracts/src/aspect-ratio.ts | 7 ++++ packages/contracts/src/badge.ts | 23 ++++++++++++ packages/contracts/src/button-group.ts | 19 ++++++++++ packages/contracts/src/calendar.ts | 8 +++++ packages/contracts/src/carousel.ts | 19 ++++++++++ packages/contracts/src/chart.ts | 7 ++++ packages/contracts/src/combobox.ts | 8 +++++ packages/contracts/src/command.ts | 8 +++++ packages/contracts/src/context-menu.ts | 7 ++++ packages/contracts/src/date-picker.ts | 7 ++++ packages/contracts/src/dialog.ts | 8 +++++ packages/contracts/src/drawer.ts | 8 +++++ packages/contracts/src/empty.ts | 19 ++++++++++ packages/contracts/src/hover-card.ts | 7 ++++ packages/contracts/src/index.ts | 43 +++++++++++++++++++++++ packages/contracts/src/input-otp.ts | 7 ++++ packages/contracts/src/item.ts | 37 +++++++++++++++++++ packages/contracts/src/kbd.ts | 7 ++++ packages/contracts/src/native-select.ts | 19 ++++++++++ packages/contracts/src/navigation-menu.ts | 7 ++++ packages/contracts/src/pagination.ts | 7 ++++ packages/contracts/src/popover.ts | 7 ++++ packages/contracts/src/progress.ts | 7 ++++ packages/contracts/src/radio-group.ts | 7 ++++ packages/contracts/src/resizable.ts | 19 ++++++++++ packages/contracts/src/scroll-area.ts | 7 ++++ packages/contracts/src/sheet.ts | 8 +++++ packages/contracts/src/skeleton.ts | 7 ++++ packages/contracts/src/slider.ts | 20 +++++++++++ packages/contracts/src/sonner.ts | 7 ++++ packages/contracts/src/spinner.ts | 7 ++++ packages/contracts/src/switch.ts | 19 ++++++++++ packages/contracts/src/tabs.ts | 20 +++++++++++ packages/contracts/src/toggle-group.ts | 8 +++++ packages/contracts/src/toggle.ts | 27 ++++++++++++++ packages/contracts/src/tooltip.ts | 8 +++++ 39 files changed, 495 insertions(+) create mode 100644 packages/contracts/src/accordion.ts create mode 100644 packages/contracts/src/alert-dialog.ts create mode 100644 packages/contracts/src/alert.ts create mode 100644 packages/contracts/src/aspect-ratio.ts create mode 100644 packages/contracts/src/badge.ts create mode 100644 packages/contracts/src/button-group.ts create mode 100644 packages/contracts/src/calendar.ts create mode 100644 packages/contracts/src/carousel.ts create mode 100644 packages/contracts/src/chart.ts create mode 100644 packages/contracts/src/combobox.ts create mode 100644 packages/contracts/src/command.ts create mode 100644 packages/contracts/src/context-menu.ts create mode 100644 packages/contracts/src/date-picker.ts create mode 100644 packages/contracts/src/dialog.ts create mode 100644 packages/contracts/src/drawer.ts create mode 100644 packages/contracts/src/empty.ts create mode 100644 packages/contracts/src/hover-card.ts create mode 100644 packages/contracts/src/input-otp.ts create mode 100644 packages/contracts/src/item.ts create mode 100644 packages/contracts/src/kbd.ts create mode 100644 packages/contracts/src/native-select.ts create mode 100644 packages/contracts/src/navigation-menu.ts create mode 100644 packages/contracts/src/pagination.ts create mode 100644 packages/contracts/src/popover.ts create mode 100644 packages/contracts/src/progress.ts create mode 100644 packages/contracts/src/radio-group.ts create mode 100644 packages/contracts/src/resizable.ts create mode 100644 packages/contracts/src/scroll-area.ts create mode 100644 packages/contracts/src/sheet.ts create mode 100644 packages/contracts/src/skeleton.ts create mode 100644 packages/contracts/src/slider.ts create mode 100644 packages/contracts/src/sonner.ts create mode 100644 packages/contracts/src/spinner.ts create mode 100644 packages/contracts/src/switch.ts create mode 100644 packages/contracts/src/tabs.ts create mode 100644 packages/contracts/src/toggle-group.ts create mode 100644 packages/contracts/src/toggle.ts create mode 100644 packages/contracts/src/tooltip.ts diff --git a/packages/contracts/src/accordion.ts b/packages/contracts/src/accordion.ts new file mode 100644 index 0000000..c80b0be --- /dev/null +++ b/packages/contracts/src/accordion.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const accordionContract = defineContract({ + docs: { + description: + 'A vertically stacked set of interactive headings that each reveal a section of content.', + }, +}); diff --git a/packages/contracts/src/alert-dialog.ts b/packages/contracts/src/alert-dialog.ts new file mode 100644 index 0000000..ad00d0b --- /dev/null +++ b/packages/contracts/src/alert-dialog.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const alertDialogContract = defineContract({ + docs: { + description: + 'A modal dialog that interrupts the user with important content and expects a response.', + }, +}); diff --git a/packages/contracts/src/alert.ts b/packages/contracts/src/alert.ts new file mode 100644 index 0000000..02012bf --- /dev/null +++ b/packages/contracts/src/alert.ts @@ -0,0 +1,19 @@ +import { defineContract } from './define-contract.js'; + +export const alertContract = defineContract({ + props: { + variants: ['default', 'destructive'], + }, + defaults: { + variants: 'default', + }, + docs: { + description: 'Displays a short, prominent message to draw attention to information.', + variants: { + default: 'Neutral informational message.', + destructive: 'Danger-tinted message for errors or warnings.', + }, + }, +}); + +export type AlertVariantName = (typeof alertContract.props.variants)[number]; diff --git a/packages/contracts/src/aspect-ratio.ts b/packages/contracts/src/aspect-ratio.ts new file mode 100644 index 0000000..1cbd37c --- /dev/null +++ b/packages/contracts/src/aspect-ratio.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const aspectRatioContract = defineContract({ + docs: { + description: 'Constrains content to a fixed width/height ratio.', + }, +}); diff --git a/packages/contracts/src/badge.ts b/packages/contracts/src/badge.ts new file mode 100644 index 0000000..c2816a3 --- /dev/null +++ b/packages/contracts/src/badge.ts @@ -0,0 +1,23 @@ +import { defineContract } from './define-contract.js'; + +export const badgeContract = defineContract({ + props: { + variants: ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'], + }, + defaults: { + variants: 'default', + }, + docs: { + description: 'A small label for status, category, or count.', + variants: { + default: 'Primary brand fill — the most prominent badge style.', + secondary: 'Muted fill — general-purpose secondary label.', + destructive: 'Danger tint — errors, warnings, or destructive states.', + outline: 'Bordered, transparent fill — low-emphasis label.', + ghost: 'No background or border — minimal label.', + link: 'Looks like a hyperlink — inline actionable label.', + }, + }, +}); + +export type BadgeVariantName = (typeof badgeContract.props.variants)[number]; diff --git a/packages/contracts/src/button-group.ts b/packages/contracts/src/button-group.ts new file mode 100644 index 0000000..4912add --- /dev/null +++ b/packages/contracts/src/button-group.ts @@ -0,0 +1,19 @@ +import { defineContract } from './define-contract.js'; + +export const buttonGroupContract = defineContract({ + props: { + orientations: ['horizontal', 'vertical'], + }, + defaults: { + orientations: 'horizontal', + }, + docs: { + description: 'Groups related buttons together with connected borders.', + orientations: { + horizontal: 'Buttons laid out in a row — the default.', + vertical: 'Buttons stacked in a column.', + }, + }, +}); + +export type ButtonGroupOrientationName = (typeof buttonGroupContract.props.orientations)[number]; diff --git a/packages/contracts/src/calendar.ts b/packages/contracts/src/calendar.ts new file mode 100644 index 0000000..0e64a0c --- /dev/null +++ b/packages/contracts/src/calendar.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const calendarContract = defineContract({ + docs: { + description: + 'A date field component that allows users to enter and edit dates, or pick a date from a visual calendar.', + }, +}); diff --git a/packages/contracts/src/carousel.ts b/packages/contracts/src/carousel.ts new file mode 100644 index 0000000..8a4abd6 --- /dev/null +++ b/packages/contracts/src/carousel.ts @@ -0,0 +1,19 @@ +import { defineContract } from './define-contract.js'; + +export const carouselContract = defineContract({ + props: { + orientations: ['horizontal', 'vertical'], + }, + defaults: { + orientations: 'horizontal', + }, + docs: { + description: 'A carousel of slides that can be navigated with buttons or gestures.', + orientations: { + horizontal: 'Slides pan left/right — the default.', + vertical: 'Slides pan up/down.', + }, + }, +}); + +export type CarouselOrientationName = (typeof carouselContract.props.orientations)[number]; diff --git a/packages/contracts/src/chart.ts b/packages/contracts/src/chart.ts new file mode 100644 index 0000000..7f346ab --- /dev/null +++ b/packages/contracts/src/chart.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const chartContract = defineContract({ + docs: { + description: 'Composable chart primitives built on Recharts, themeable via CSS variables.', + }, +}); diff --git a/packages/contracts/src/combobox.ts b/packages/contracts/src/combobox.ts new file mode 100644 index 0000000..4b474a0 --- /dev/null +++ b/packages/contracts/src/combobox.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const comboboxContract = defineContract({ + docs: { + description: + 'A searchable, filterable select input combining a text field with a listbox of options.', + }, +}); diff --git a/packages/contracts/src/command.ts b/packages/contracts/src/command.ts new file mode 100644 index 0000000..48fc333 --- /dev/null +++ b/packages/contracts/src/command.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const commandContract = defineContract({ + docs: { + description: + 'A fast, composable command palette and searchable list for actions and navigation.', + }, +}); diff --git a/packages/contracts/src/context-menu.ts b/packages/contracts/src/context-menu.ts new file mode 100644 index 0000000..90d4cbd --- /dev/null +++ b/packages/contracts/src/context-menu.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const contextMenuContract = defineContract({ + docs: { + description: 'A menu of actions revealed by right-clicking (or long-pressing) a trigger area.', + }, +}); diff --git a/packages/contracts/src/date-picker.ts b/packages/contracts/src/date-picker.ts new file mode 100644 index 0000000..a44405d --- /dev/null +++ b/packages/contracts/src/date-picker.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const datePickerContract = defineContract({ + docs: { + description: 'A calendar-backed input for picking a single date or a date range.', + }, +}); diff --git a/packages/contracts/src/dialog.ts b/packages/contracts/src/dialog.ts new file mode 100644 index 0000000..7b8316c --- /dev/null +++ b/packages/contracts/src/dialog.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const dialogContract = defineContract({ + docs: { + description: + 'A window overlaid on the page, rendering content that requires attention or actions.', + }, +}); diff --git a/packages/contracts/src/drawer.ts b/packages/contracts/src/drawer.ts new file mode 100644 index 0000000..356c2e4 --- /dev/null +++ b/packages/contracts/src/drawer.ts @@ -0,0 +1,8 @@ +import { defineContract } from './define-contract.js'; + +export const drawerContract = defineContract({ + docs: { + description: + 'A panel that slides in from an edge of the screen, typically for mobile-style navigation or actions.', + }, +}); diff --git a/packages/contracts/src/empty.ts b/packages/contracts/src/empty.ts new file mode 100644 index 0000000..df97e1d --- /dev/null +++ b/packages/contracts/src/empty.ts @@ -0,0 +1,19 @@ +import { defineContract } from './define-contract.js'; + +export const emptyContract = defineContract({ + props: { + variants: ['default', 'icon'], + }, + defaults: { + variants: 'default', + }, + docs: { + description: 'A placeholder for empty states — no data, no results, or an error.', + variants: { + default: 'Plain media wrapper with no background — for a custom illustration.', + icon: 'Rounded icon badge background for an icon or short glyph.', + }, + }, +}); + +export type EmptyMediaVariantName = (typeof emptyContract.props.variants)[number]; diff --git a/packages/contracts/src/hover-card.ts b/packages/contracts/src/hover-card.ts new file mode 100644 index 0000000..4ceca94 --- /dev/null +++ b/packages/contracts/src/hover-card.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const hoverCardContract = defineContract({ + docs: { + description: 'Displays a preview of content when hovering over a trigger.', + }, +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9b05c39..d30e162 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,21 +1,64 @@ +export { accordionContract } from './accordion.js'; +export { alertContract, type AlertVariantName } from './alert.js'; +export { alertDialogContract } from './alert-dialog.js'; +export { aspectRatioContract } from './aspect-ratio.js'; export { avatarContract, type AvatarSizeName } from './avatar.js'; +export { badgeContract, type BadgeVariantName } from './badge.js'; export { cardContract } from './card.js'; export { breadcrumbContract } from './breadcrumb.js'; export { buttonContract, type ButtonVariantName, type ButtonSizeName } from './button.js'; +export { buttonGroupContract, type ButtonGroupOrientationName } from './button-group.js'; +export { calendarContract } from './calendar.js'; +export { carouselContract, type CarouselOrientationName } from './carousel.js'; +export { chartContract } from './chart.js'; export { checkboxContract } from './checkbox.js'; +export { comboboxContract } from './combobox.js'; +export { commandContract } from './command.js'; +export { contextMenuContract } from './context-menu.js'; export { dataTableContract } from './data-table.js'; +export { datePickerContract } from './date-picker.js'; +export { dialogContract } from './dialog.js'; +export { drawerContract } from './drawer.js'; export { dropdownMenuContract, type DropdownMenuItemVariantName } from './dropdown-menu.js'; +export { emptyContract, type EmptyMediaVariantName } from './empty.js'; export { fieldContract, type FieldOrientationName } from './field.js'; +export { hoverCardContract } from './hover-card.js'; export { inputContract } from './input.js'; export { inputGroupContract } from './input-group.js'; +export { inputOtpContract } from './input-otp.js'; +export { + itemContract, + type ItemVariantName, + type ItemSizeName, + type ItemMediaVariantName, +} from './item.js'; +export { kbdContract } from './kbd.js'; export { labelContract } from './label.js'; +export { nativeSelectContract, type NativeSelectSizeName } from './native-select.js'; +export { navigationMenuContract } from './navigation-menu.js'; +export { paginationContract } from './pagination.js'; +export { popoverContract } from './popover.js'; +export { progressContract } from './progress.js'; +export { radioGroupContract } from './radio-group.js'; +export { resizableContract, type ResizableDirectionName } from './resizable.js'; +export { scrollAreaContract } from './scroll-area.js'; export { selectContract, type SelectTriggerSizeName } from './select.js'; export { separatorContract, type SeparatorOrientationName } from './separator.js'; +export { sheetContract } from './sheet.js'; export { sidebarContract, sidebarMenuButtonContract, type SidebarMenuButtonVariantName, type SidebarMenuButtonSizeName, } from './sidebar.js'; +export { skeletonContract } from './skeleton.js'; +export { sliderContract, type SliderOrientationName } from './slider.js'; +export { sonnerContract } from './sonner.js'; +export { spinnerContract } from './spinner.js'; +export { switchContract, type SwitchSizeName } from './switch.js'; export { tableContract } from './table.js'; +export { tabsContract, type TabsVariantName } from './tabs.js'; export { textareaContract } from './textarea.js'; +export { toggleContract, type ToggleVariantName, type ToggleSizeName } from './toggle.js'; +export { toggleGroupContract } from './toggle-group.js'; +export { tooltipContract } from './tooltip.js'; diff --git a/packages/contracts/src/input-otp.ts b/packages/contracts/src/input-otp.ts new file mode 100644 index 0000000..cb6bbe4 --- /dev/null +++ b/packages/contracts/src/input-otp.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const inputOtpContract = defineContract({ + docs: { + description: 'A one-time-password input split across individual character slots.', + }, +}); diff --git a/packages/contracts/src/item.ts b/packages/contracts/src/item.ts new file mode 100644 index 0000000..daf9fab --- /dev/null +++ b/packages/contracts/src/item.ts @@ -0,0 +1,37 @@ +import { defineContract } from './define-contract.js'; + +export const itemContract = defineContract({ + props: { + variants: ['default', 'outline', 'muted'], + sizes: ['default', 'sm', 'xs'], + mediaVariants: ['default', 'icon', 'image'], + }, + defaults: { + variants: 'default', + sizes: 'default', + mediaVariants: 'default', + }, + docs: { + description: + 'A flexible row for lists of actions, settings, or content — media, title, description, and actions.', + variants: { + default: 'No border, transparent background.', + outline: 'Bordered container.', + muted: 'Subtle muted background, no border.', + }, + sizes: { + default: 'Standard padding and gaps.', + sm: 'Compact padding and gaps.', + xs: 'Extra compact — dense lists such as menus.', + }, + mediaVariants: { + default: 'No styling — media renders as-is.', + icon: 'Rounded icon badge background.', + image: 'Fixed square image thumbnail.', + }, + }, +}); + +export type ItemVariantName = (typeof itemContract.props.variants)[number]; +export type ItemSizeName = (typeof itemContract.props.sizes)[number]; +export type ItemMediaVariantName = (typeof itemContract.props.mediaVariants)[number]; diff --git a/packages/contracts/src/kbd.ts b/packages/contracts/src/kbd.ts new file mode 100644 index 0000000..7b977d1 --- /dev/null +++ b/packages/contracts/src/kbd.ts @@ -0,0 +1,7 @@ +import { defineContract } from './define-contract.js'; + +export const kbdContract = defineContract({ + docs: { + description: 'Represents a keyboard key or shortcut combination.', + }, +}); diff --git a/packages/contracts/src/native-select.ts b/packages/contracts/src/native-select.ts new file mode 100644 index 0000000..bb90005 --- /dev/null +++ b/packages/contracts/src/native-select.ts @@ -0,0 +1,19 @@ +import { defineContract } from './define-contract.js'; + +export const nativeSelectContract = defineContract({ + props: { + sizes: ['default', 'sm'], + }, + defaults: { + sizes: 'default', + }, + docs: { + description: 'A styled native ` flex to fill the space. + */ +export const WithInput: Story = { + render: () => ( + + + + + + ), +}; diff --git a/packages/react/src/components/ui/button-group/button-group.tsx b/packages/react/src/components/ui/button-group/button-group.tsx new file mode 100644 index 0000000..4fca50e --- /dev/null +++ b/packages/react/src/components/ui/button-group/button-group.tsx @@ -0,0 +1,79 @@ +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ButtonGroupOrientationName } from '@surfnet/curve-contracts'; + +import { cn } from '@/lib/utils'; +import { Separator } from '@/components/ui/separator'; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + '*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0', + vertical: + 'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0', + } satisfies Record, + }, + defaultVariants: { + orientation: 'horizontal', + }, + }, +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<'div'>) { + return useRender({ + defaultTagName: 'div', + props: mergeProps<'div'>( + { + className: cn( + "flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className, + ), + }, + props, + ), + render, + state: { + slot: 'button-group-text', + }, + }); +} + +function ButtonGroupSeparator({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; diff --git a/packages/react/src/components/ui/button-group/index.ts b/packages/react/src/components/ui/button-group/index.ts new file mode 100644 index 0000000..cc9e52d --- /dev/null +++ b/packages/react/src/components/ui/button-group/index.ts @@ -0,0 +1 @@ +export * from './button-group'; diff --git a/packages/react/src/components/ui/calendar/calendar.stories.tsx b/packages/react/src/components/ui/calendar/calendar.stories.tsx new file mode 100644 index 0000000..83c1dff --- /dev/null +++ b/packages/react/src/components/ui/calendar/calendar.stories.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import type { DateRange } from 'react-day-picker'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { calendarContract } from '@surfnet/curve-contracts'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover/popover'; + +import { Calendar } from './calendar'; + +const meta = { + title: 'Components/Calendar', + component: Calendar, + parameters: { + docs: { + description: { + component: calendarContract.docs.description, + }, + }, + }, + argTypes: { + captionLayout: { + control: 'select', + options: ['label', 'dropdown', 'dropdown-months', 'dropdown-years'], + description: 'How the month/year caption is rendered — a plain label or dropdown selectors.', + table: { + defaultValue: { summary: 'label' }, + }, + }, + showOutsideDays: { + control: 'boolean', + description: 'Show the leading/trailing days of adjacent months to fill out the grid.', + table: { + defaultValue: { summary: 'true' }, + }, + }, + }, + args: { + captionLayout: 'label', + showOutsideDays: true, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** A plain month grid with no selection wired up — tweak the caption and outside-days controls. */ +export const Default: Story = {}; + +/** Single-date selection, the most common mode, driven by React state. */ +export const Single: Story = { + render: function SingleCalendar() { + const [date, setDate] = React.useState(new Date()); + return ( + + ); + }, +}; + +/** Multiple, individually toggled dates. */ +export const Multiple: Story = { + render: function MultipleCalendar() { + const [dates, setDates] = React.useState(undefined); + return ( + + ); + }, +}; + +/** A contiguous range with a start and end date. */ +export const Range: Story = { + render: function RangeCalendar() { + const [range, setRange] = React.useState(undefined); + return ( + + ); + }, +}; + +/** Two months side by side — the layout typically used for a date-range picker. */ +export const TwoMonths: Story = { + render: function TwoMonthsCalendar() { + const [range, setRange] = React.useState(undefined); + return ( + + ); + }, +}; + +/** Dropdown month/year selectors instead of a static caption — useful for jumping across years (e.g. a birthdate field). */ +export const DropdownCaption: Story = { + render: function DropdownCaptionCalendar() { + const [date, setDate] = React.useState(new Date()); + return ( + + ); + }, +}; + +/** Weekends disabled via a day-of-week matcher — disabled days cannot be selected. */ +export const DisabledDates: Story = { + render: function DisabledDatesCalendar() { + const [date, setDate] = React.useState(undefined); + return ( + + ); + }, +}; + +/** A footer acting as a live region, announcing the current selection — recommended for accessibility. */ +export const WithFooter: Story = { + render: function WithFooterCalendar() { + const [date, setDate] = React.useState(undefined); + return ( + + ); + }, +}; + +/** + * Composed inside a Card — the calendar's own styles already treat a parent + * `data-slot="card-content"` as transparent, so it blends with the card background. + */ +export const InCard: Story = { + render: function CalendarInCard() { + const [date, setDate] = React.useState(new Date()); + return ( + + + + + + ); + }, +}; + +/** + * Composed inside a Popover trigger — the classic date-picker anatomy (trigger + popover + * content). See the dedicated `DatePicker` component for a ready-made version of this pattern. + */ +export const InPopover: Story = { + render: function CalendarInPopover() { + const [date, setDate] = React.useState(undefined); + return ( + + } + > + {date ? date.toLocaleDateString() : 'Pick a date'} + + + + + + ); + }, +}; diff --git a/packages/react/src/components/ui/calendar/calendar.tsx b/packages/react/src/components/ui/calendar/calendar.tsx new file mode 100644 index 0000000..d497bf0 --- /dev/null +++ b/packages/react/src/components/ui/calendar/calendar.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { CaretLeftIcon, CaretRightIcon, CaretDownIcon } from '@phosphor-icons/react'; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant']; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className, + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }), + ...formatters, + }} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months), + month: cn('flex w-full flex-col gap-4', defaultClassNames.month), + nav: cn( + 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', + defaultClassNames.nav, + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_previous, + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', + defaultClassNames.button_next, + ), + month_caption: cn( + 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', + defaultClassNames.month_caption, + ), + dropdowns: cn( + 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', + defaultClassNames.dropdowns, + ), + dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root), + dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown), + caption_label: cn( + 'font-medium select-none', + captionLayout === 'label' + ? 'text-sm' + : 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground', + defaultClassNames.caption_label, + ), + month_grid: cn('w-full border-collapse', defaultClassNames.month_grid), + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none', + defaultClassNames.weekday, + ), + week: cn('mt-2 flex w-full', defaultClassNames.week), + week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header), + week_number: cn( + 'text-[0.8rem] text-muted-foreground select-none', + defaultClassNames.week_number, + ), + day: cn( + 'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)', + props.showWeekNumber + ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)' + : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)', + defaultClassNames.day, + ), + range_start: cn( + 'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted', + defaultClassNames.range_start, + ), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn( + 'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted', + defaultClassNames.range_end, + ), + today: cn( + 'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none', + defaultClassNames.today, + ), + outside: cn( + 'text-muted-foreground aria-selected:text-muted-foreground', + defaultClassNames.outside, + ), + disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return
; + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return ; + } + + if (orientation === 'right') { + return ; + } + + return ; + }, + DayButton: ({ ...props }) => , + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = 'outline', + size = 'icon-sm', + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/packages/react/src/components/ui/carousel/index.ts b/packages/react/src/components/ui/carousel/index.ts new file mode 100644 index 0000000..5f66438 --- /dev/null +++ b/packages/react/src/components/ui/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel'; diff --git a/packages/react/src/components/ui/chart/chart.stories.tsx b/packages/react/src/components/ui/chart/chart.stories.tsx new file mode 100644 index 0000000..6acd13e --- /dev/null +++ b/packages/react/src/components/ui/chart/chart.stories.tsx @@ -0,0 +1,174 @@ +import { DesktopIcon, DeviceMobileIcon } from '@phosphor-icons/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { chartContract } from '@surfnet/curve-contracts'; +import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis } from 'recharts'; + +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from './chart'; + +const monthlyData = [ + { month: 'January', desktop: 186, mobile: 80 }, + { month: 'February', desktop: 305, mobile: 200 }, + { month: 'March', desktop: 237, mobile: 120 }, + { month: 'April', desktop: 73, mobile: 190 }, + { month: 'May', desktop: 209, mobile: 130 }, + { month: 'June', desktop: 214, mobile: 140 }, +]; + +const deviceConfig = { + desktop: { + label: 'Desktop', + color: 'var(--chart-1)', + }, + mobile: { + label: 'Mobile', + color: 'var(--chart-2)', + }, +} satisfies ChartConfig; + +const formatMonth = (value: string) => value.slice(0, 3); + +const meta = { + title: 'Components/Chart', + component: ChartContainer, + parameters: { + docs: { + description: { + component: chartContract.docs.description, + }, + }, + }, + // `config` and `children` have no sensible generic defaults (every story composes its own + // Recharts tree), so every story below supplies its own `render` and ignores these args — + // they only exist to satisfy `ChartContainer`'s required props for the story type. + args: { + config: deviceConfig, + children: ( + + + + + ), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * A bar chart composed from `ChartContainer`, `ChartTooltip`, and `ChartLegend` — the + * component's primary, realistic usage. Colors come from the `config` map, which + * `ChartContainer` turns into `--color-*` CSS variables scoped to the chart. + */ +export const Default: Story = { + render: () => ( + + + + + } /> + } /> + + + + + ), +}; + +/** A line chart, showing the same primitives compose with a different Recharts chart type. */ +export const LineChartExample: Story = { + render: () => ( + + + + + } /> + } /> + + + + + ), +}; + +/** + * `ChartLegendContent` falls back to a Phosphor icon per series instead of a color swatch + * when the matching `config` entry declares one. + */ +export const WithIconLegend: Story = { + render: () => { + const config = { + desktop: { label: 'Desktop', icon: DesktopIcon, color: 'var(--chart-1)' }, + mobile: { label: 'Mobile', icon: DeviceMobileIcon, color: 'var(--chart-2)' }, + } satisfies ChartConfig; + + return ( + + + + + } /> + } /> + + + + + ); + }, +}; + +/** + * `ChartTooltipContent`'s `indicator` prop swaps the swatch next to each series in the + * hover tooltip. Hover each chart to compare `dot` (default), `line`, and `dashed`. + */ +export const TooltipIndicators: Story = { + render: () => ( +
+ {(['dot', 'line', 'dashed'] as const).map((indicator) => ( +
+ {indicator} + + + + + } /> + + + + +
+ ))} +
+ ), +}; diff --git a/packages/react/src/components/ui/chart/chart.tsx b/packages/react/src/components/ui/chart/chart.tsx new file mode 100644 index 0000000..6dff0ee --- /dev/null +++ b/packages/react/src/components/ui/chart/chart.tsx @@ -0,0 +1,337 @@ +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; +import type { TooltipValueType } from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; + initialDimension?: { + width: number; + height: number; + }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +