diff --git a/.github/workflows/reusable-workflows.yml b/.github/workflows/reusable-workflows.yml index b517200783..78d10e88c3 100644 --- a/.github/workflows/reusable-workflows.yml +++ b/.github/workflows/reusable-workflows.yml @@ -2,6 +2,7 @@ name: Reusable Workflows on: pull_request: + types: [opened, reopened, synchronize, edited] jobs: pr-title-check: diff --git a/docs/components/Data Display/Avatar/Documentation.mdx b/docs/components/Data Display/Avatar/Documentation.mdx index 77bed11116..afd062b9ef 100644 --- a/docs/components/Data Display/Avatar/Documentation.mdx +++ b/docs/components/Data Display/Avatar/Documentation.mdx @@ -6,18 +6,31 @@ import * as AvatarStories from '../../../../src/components/data-display/Avatar/A # Avatar -#### Overview +#### Shape -The **Avatar** component is used to display a profile image, initials, or an icon, commonly for identification purposes. +The default avatar is circular. Switch the `shape` prop to `square` when you need hard edges or when aligning with adjacent rectangular content. -#### When to use + -- To represent an account, organization, or workspace. -- To represent a single user or as a placeholder when a user profile image isn’t available. +#### Sizes -#### Current Usage + -- **[Navigation](https://mparticle.github.io/aquarium/?path=/story/components-navigation-globalnavigation--primary)** – Avatar is used within the navigation sidebar to represent the selected workspace. +#### Photo avatar + + + +#### Icon avatar + + + +#### Initial avatar + + + +#### Badge avatars + + #### Related Links @@ -26,7 +39,3 @@ The **Avatar** component is used to display a profile image, initials, or an ico | Eames | [Avatar Component](https://www.figma.com/design/veXnmignQnJz8StIq10VJ5/Eames-2.0---Foundations-%26-Components?node-id=397-12044&node-type=canvas&t=B6HJWqqDsUOypZQj-0) | | AntD | [Avatar Component](https://ant.design/components/avatar) | - - - - diff --git a/docs/components/Data Display/Badge/Documentation.mdx b/docs/components/Data Display/Badge/Documentation.mdx index 96aa428d1d..4146bb68e2 100644 --- a/docs/components/Data Display/Badge/Documentation.mdx +++ b/docs/components/Data Display/Badge/Documentation.mdx @@ -12,23 +12,21 @@ import * as BadgeStories from '../../../../src/components/data-display/Badge/Bad The **Badge** component provides visual indicators for an item’s status, available in two styles: a **dot** and a \*\*status badge" with text. -### [Dot Badge](https://mparticle.github.io/aquarium/?path=/story/components-data-display-badge--dot-badge) +#### Status badge -#### When to use +Use status badges to indicate where an item sits in a process with a clear beginning and end. Pair the dot with concise labels so the current state is obvious without extra context. -Use **Badge** when a status needs to be displayed next to an element in a compact form. + -#### Current Usages +#### Dot badge -- **[Navigation](https://mparticle.github.io/aquarium/?path=/story/components-navigation-globalnavigation--primary)** – Badge is used within the navigation sidebar for notification use cases. +Use dot badges to surface the current status of an item without additional copy. Each dot maps to the core status tokens, including the paused state for work that is temporarily on hold. -#### Related Links +#### Related links | Type | Resource | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Eames | [Badge Component](https://www.figma.com/design/veXnmignQnJz8StIq10VJ5/Eames-2.0---Foundations-%26-Components?node-id=399-0&node-type=canvas&t=B6HJWqqDsUOypZQj-0) | | AntD | [Badge Component](https://ant.design/components/badge) | - - diff --git a/docs/components/Data Display/Popover/Documentation.mdx b/docs/components/Data Display/Popover/Documentation.mdx index 24c9d6194f..750ae96235 100644 --- a/docs/components/Data Display/Popover/Documentation.mdx +++ b/docs/components/Data Display/Popover/Documentation.mdx @@ -1,6 +1,6 @@ import { Meta, Canvas } from '@storybook/blocks' -import * as PopoverStories from '../../../../src/components/data-display/Popover/Popover.stories' +import PopoverStories, { Primary as PopoverPrimary } from '../../../../src/components/data-display/Popover/Popover.stories' @@ -34,4 +34,4 @@ Use [Tooltip](https://mparticle.github.io/aquarium/?path=/story/components-data- | AntD | [Popover Component](https://ant.design/components/popover) | - + diff --git a/docs/components/Data Display/Segmented/Documentation.mdx b/docs/components/Data Display/Segmented/Documentation.mdx index 2ab4a2ff64..9604bbbae3 100644 --- a/docs/components/Data Display/Segmented/Documentation.mdx +++ b/docs/components/Data Display/Segmented/Documentation.mdx @@ -4,12 +4,26 @@ import * as SegmentedStories from '../../../../src/components/data-display/Segme -{/* Documentation goes here */} - # Segmented -This is the documentation for the Segmenteds component +#### Overview + +The **Segmented** component presents a compact set of mutually exclusive choices. Each option is easy to scan and the selected value is highlighted to confirm the current state. + +#### Small segmented control + +Use the small size when the control sits inside dense layouts such as tables or cards with limited space. + + + +#### Large segmented control + +Use the large size for standalone toggles where the segment labels need more breathing room and legibility. + + -{/* Documentation goes here */} +#### Related links - +| Type | Resource | +| ---- | -------- | +| AntD | [Segmented Component](https://ant.design/components/segmented) | diff --git a/docs/components/Data Entry/Checkbox/Documentation.mdx b/docs/components/Data Entry/Checkbox/Documentation.mdx index 922e5016b9..d14aeb07dd 100644 --- a/docs/components/Data Entry/Checkbox/Documentation.mdx +++ b/docs/components/Data Entry/Checkbox/Documentation.mdx @@ -19,16 +19,38 @@ Use the **Checkbox** component when: - For binary options, especially when changes require saving. - For active agreements, such as accepting terms of service. -When selecting a single option from a set of mutually exclusive choices, use **[Radio Buttons](https://mparticle.github.io/aquarium/?path=/story/components-data-entry-radio--primary)**. +### Default checkbox -Use **[switch](https://mparticle.github.io/aquarium/?path=/story/components-data-entry-switch--primary)** for cases like activation, filter controls, or subscriptions where there is a clear "on/off" state. +Start with the standard checkbox for simple agree-or-disagree prompts. -#### Related Links + -| Type | Resource | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Eames | [Checkbox Component](https://www.figma.com/design/veXnmignQnJz8StIq10VJ5/Eames-2.0---Foundations-%26-Components?node-id=388-11036&p=f&t=zURbLpG60aM6aJiH-0) | -| AntD | [Checkbox Component](https://ant.design/components/checkbox) | +### Checkbox states +Reference this overview to show each visual state side by side—interactive default, checked, indeterminate, and disabled. - + + +### Checkbox groups + +Group related choices with `Checkbox.Group` so selections stay in sync. Pair the group with a leading Checkbox to provide a “Select all” control when the pattern fits the use case. + + + +### Disabled with tooltip + +Pair a disabled checkbox with a tooltip so users understand why the option is locked or how to gain access. + + + +### Stacked checkbox list + +Use individual Checkbox components when you need richer labels and supporting text. Keep the layout vertically aligned to make scanning easy. + + + +### Long consent copy + +Pair a checkbox with structured rich text for legal agreements. Highlight the primary sentence and keep supporting copy visually secondary. + + diff --git a/docs/components/General/Button/Documentation.mdx b/docs/components/General/Button/Documentation.mdx index 593c50e283..244cfd710f 100644 --- a/docs/components/General/Button/Documentation.mdx +++ b/docs/components/General/Button/Documentation.mdx @@ -1,4 +1,4 @@ -import { Meta, Canvas } from '@storybook/blocks' +import { Meta, Canvas, Story } from '@storybook/blocks' import * as ButtonStories from '../../../../src/components/general/Button/Button.stories' @@ -8,82 +8,59 @@ import * as ButtonStories from '../../../../src/components/general/Button/Button # Button -#### Overview +### Overview The **Button** component is used to trigger actions or navigate the interface. It supports various styles, sizes, and states to fit different use cases. -#### Key Features +### Button Types -- **Variants:** Includes primary, default, dashed, text, and link buttons for different use cases. -- **Sizes:** Supports small, medium (default), and large sizes. -- **States:** Disabled, loading, and hover states to indicate action availability. -- **Icons:** Easily integrate icons for visual clarity and emphasis. +- **Primary** – High-emphasis actions like submitting or saving workflows. +- **Secondary** – Supportive tasks such as canceling or opening supplemental dialogs. +- **Tertiary** – Lower-priority options that should sit behind primary/secondary CTAs. +- **Text** – Quiet inline affordances that should blend into surrounding content. +- **Link** – Navigation-style actions that resemble hyperlinks within layouts. + -### Button Variants and Use Cases + -#### Primary Button +### Button sizes -**Use Case**: For the main action on a page or section. -- **Example:** Submitting a form, saving changes, or initiating a key process. +Buttons ship in three heights to support different layouts. The default is `middle`, shown alongside the smaller and larger options below. -**Examples**: + -- **With Icon**: - +### Buttons with icons -- **Without Icon**: - +The icon-enabled variants mirror the same hierarchy while reinforcing meaning with glyphs. -#### Default Button + -**Use Case**: For secondary actions that complement the primary task. -- **Example:** Canceling an action, opening a dialog, or performing less important tasks. +#### Icon-only buttons -**Examples**: +Icon-only buttons provide compact controls for toolbars and quick actions where text isn’t necessary. -- **With Icon**: - - -- **Without Icon**: - - -#### Dashed Button - -**Use Case**: For tertiary actions in the visual hierarchy. - -**Examples**: - -- **With Icon**: - - -- **Without Icon**: - + -#### Link Button +### Loading state -**Use Case**: Ideal for triggering an actions that don't require heavy visual emphasis. -- **Example:** Viewing errors, navigating to documentation -When navigating to a new URL or external page, always use [Typography.Link](https://mparticle.github.io/aquarium/?path=/story/components-general-typography-link--primary) instead of a Link Button. +The loading variant keeps the user informed while an action finishes processing. Use concise labels that set expectations. -**Examples**: + -- **With Icon**: - +### Loading text button -- **Without Icon**: - +Use the text treatment for subtle “load more” affordances that need to display progress without taking extra visual weight. -#### Icon-Only Button + -**Use Case**: For compact controls where only an icon is needed. -- **Example:** Toolbar controls, close buttons, or quick actions. +### Destructive action -**Examples**: - +Use the danger style to signal irreversible steps such as deletions. Pair it with clear language so users understand the impact. + -#### Related Links +### Related Links | Type | Resource | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/components/UX Patterns/StatisticsCard/Documentation.mdx b/docs/components/UX Patterns/StatisticsCard/Documentation.mdx new file mode 100644 index 0000000000..79023390dd --- /dev/null +++ b/docs/components/UX Patterns/StatisticsCard/Documentation.mdx @@ -0,0 +1,23 @@ +import { Meta, Canvas } from '@storybook/blocks' + +import StatisticsCardStories, { ModelMetrics } from '../../../../src/components/UXPatterns/StatisticsCard/StatisticsCard.stories' + + + +# Statistics Card + +A card component for displaying metric values with optional tooltips, commonly used for model metrics, performance indicators, or key statistics. + +## When to Use + +- Displaying model performance metrics (accuracy, precision, recall, AUC) +- Showing key performance indicators (KPIs) +- Presenting statistical data with contextual help +- Creating metric dashboards or summary cards + +## Model Metrics Example + +A common pattern showing multiple metrics in a grid layout: + + + diff --git a/docs/components/UX Patterns/Steps/Documentation.mdx b/docs/components/UX Patterns/Steps/Documentation.mdx new file mode 100644 index 0000000000..40a868b01c --- /dev/null +++ b/docs/components/UX Patterns/Steps/Documentation.mdx @@ -0,0 +1,12 @@ +import { Meta, Canvas } from '@storybook/blocks' + +import StepsStories, { UXPatternExample } from '../../../../src/components/UXPatterns/Steps/Steps.stories' + + + +# Steps Progress Indicator + +Steps help communicate the current state of multistep flows. The following UX pattern centers the vertical label placement version inside its container so it feels like a dashboard callout. + + + diff --git a/src/components/POC/rokt-catalog/ExpandedSidebar.stories.tsx b/src/components/POC/rokt-catalog/ExpandedSidebar.stories.tsx new file mode 100644 index 0000000000..9b1791461b --- /dev/null +++ b/src/components/POC/rokt-catalog/ExpandedSidebar.stories.tsx @@ -0,0 +1,161 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import React, { useState } from 'react' +import { + HomeOutlined, + SettingOutlined, + TagOutlined, + ShoppingCartOutlined, + ShopOutlined, + StarFilled, + AimOutlined, + FileTextOutlined, + InboxOutlined, + FolderOutlined, + SendOutlined, + LineChartOutlined, +} from '@ant-design/icons' +import { Avatar } from 'src/components' +import { ColorBorder, MpBrandSecondary5 } from 'src/styles/style' +import { ExpandedSidebar, type IExpandedSidebarItem, type IExpandedSidebarSection } from './ExpandedSidebar' + +// Simple gray avatar for additional partners +const GrayAvatar = () => ( + +) + +// Navigation items matching the Figma design +const roktNavItems: IExpandedSidebarItem[] = [ + { key: 'home', icon: , label: 'Home' }, + { key: 'settings', icon: , label: 'Settings' }, + { key: 'products', icon: , label: 'Products' }, + { key: 'orders', icon: , label: 'Orders' }, +] + +// Featured partners (polished) +const featuredPartners: IExpandedSidebarItem[] = [ + { + key: 'nordstrom', + icon: ( + + N + + ), + label: 'Nordstrom', + }, + { + key: 'macys', + icon: ( + + + + ), + label: "Macy's", + }, + { + key: 'target', + icon: ( + + + + ), + label: 'Target', + }, +] + +// Additional partners (simple gray avatars) +const additionalPartners: IExpandedSidebarItem[] = [ + { key: 'shopsimon', icon: , label: 'ShopSimon' }, + { key: 'canal-demo', icon: , label: 'Canal Demo Store' }, + { key: 'forex', icon: , label: 'Forex (Deactivated)' }, + { key: 'fellow', icon: , label: 'Fellow (Deactivated)' }, + { key: 'alyssa', icon: , label: 'Alyssa Milano (Deact...' }, + { key: 'flip', icon: , label: 'Flip (Deactivated)' }, +] + +// Function to build marketplace items with dynamic partners +const buildMarketplaceItems = (expanded: boolean, onToggle: () => void): IExpandedSidebarItem[] => [ + { + key: 'partners', + icon: , + label: 'Partners', + children: [ + ...featuredPartners, + ...(expanded ? additionalPartners : []), + { + key: 'see-toggle', + label: expanded ? 'See less' : 'See more', + onClick: onToggle, + }, + ], + }, + { key: 'proposals', icon: , label: 'Proposals', badge: 10 }, + { key: 'inbox', icon: , label: 'Inbox', badge: 10 }, + { + key: 'reports', + icon: , + label: 'Reports', + children: [ + { key: 'analytics', label: 'Analytics' }, + { key: 'payouts', label: 'Payouts' }, + ], + }, +] + +// Ads section items +const adsItems: IExpandedSidebarItem[] = [ + { key: 'campaign-manager', icon: , label: 'Campaign manager' }, + { key: 'ads-analytics', icon: , label: 'Analytics' }, +] + +// Function to build sections with dynamic marketplace items +const buildSections = (expanded: boolean, onToggle: () => void): IExpandedSidebarSection[] => [ + { label: 'MARKETPLACE', items: buildMarketplaceItems(expanded, onToggle) }, + { label: 'ADS', items: adsItems }, +] + +const meta: Meta = { + title: 'POC/Rokt Catalog/ExpandedSidebar', + component: ExpandedSidebar, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +} + +export default meta + +type Story = StoryObj + +/** + * Primary story matching the Rokt Catalog Figma design + */ +export const RoktCatalog: Story = { + render: () => { + const [expanded, setExpanded] = useState(false) + + return ( + setExpanded(!expanded))} + userProfile={{ + name: 'lily riojas', + email: 'lily@sundaycitizen.co', + isOnline: true, + onClick: () => alert('User profile clicked!'), + }} + selectedKey="home" + defaultOpenKeys={['partners', 'reports']} + /> + ) + }, +} diff --git a/src/components/POC/rokt-catalog/ExpandedSidebar.tsx b/src/components/POC/rokt-catalog/ExpandedSidebar.tsx new file mode 100644 index 0000000000..c9282984bc --- /dev/null +++ b/src/components/POC/rokt-catalog/ExpandedSidebar.tsx @@ -0,0 +1,175 @@ +import './expanded-sidebar.css' +import React, { type ReactNode } from 'react' +import { Badge, Flex, Layout, Menu, Typography, type IMenuProps } from 'src/components' +import { SidebarHeader, type ISidebarHeaderProps } from './SidebarHeader' +import { SidebarUserProfile, type ISidebarUserProfileProps } from './SidebarUserProfile' +import { ColorBorder, ColorBgLayout, ColorText, Padding } from 'src/styles/style' + +type MenuProps = IMenuProps + +export interface IExpandedSidebarItem { + key: string + label: ReactNode + icon?: ReactNode + badge?: number + children?: IExpandedSidebarItem[] + onClick?: () => void + href?: string + disabled?: boolean +} + +export interface IExpandedSidebarSection { + label: string + items: IExpandedSidebarItem[] +} + +export interface IExpandedSidebarProps { + header?: ISidebarHeaderProps + items?: IExpandedSidebarItem[] + sections?: IExpandedSidebarSection[] + userProfile?: ISidebarUserProfileProps + selectedKey?: string + defaultOpenKeys?: string[] + onSelect?: (key: string) => void + onOpenChange?: (openKeys: string[]) => void + width?: number + className?: string +} + +export function ExpandedSidebar({ + header, + items = [], + sections = [], + userProfile, + selectedKey, + defaultOpenKeys = [], + onSelect, + onOpenChange, + width = 280, + className = '', +}: IExpandedSidebarProps) { + const handleSelect: MenuProps['onSelect'] = ({ key }) => { + onSelect?.(key) + } + + const handleOpenChange: MenuProps['onOpenChange'] = openKeys => { + onOpenChange?.(openKeys) + } + + // Convert our items to Ant Menu items format + const convertToMenuItems = (navItems: IExpandedSidebarItem[]): MenuProps['items'] => { + return navItems.map(item => { + const baseItem: NonNullable[number] = { + key: item.key, + icon: item.icon, + disabled: item.disabled, + onClick: item.onClick, + } + + // Handle items with badges + if (item.badge !== undefined) { + return { + ...baseItem, + label: ( + + {item.label} + + + ), + } + } + + // Handle items with children (submenus) + if (item.children && item.children.length > 0) { + return { + ...baseItem, + label: item.label, + children: convertToMenuItems(item.children), + } + } + + // Regular items + return { + ...baseItem, + label: item.label, + } + }) + } + + // Build the complete menu items array + const buildMenuItems = (): MenuProps['items'] => { + const menuItems: MenuProps['items'] = [] + + // Add top-level items first + if (items.length > 0) { + menuItems.push(...(convertToMenuItems(items) ?? [])) + } + + // Add sections with dividers and group labels + sections.forEach((section, index) => { + // Add divider before section (except for first section if no top items) + if (items.length > 0 || index > 0) { + menuItems.push({ + type: 'divider', + key: `divider-${section.label}`, + }) + } + + // Add section group with its items + menuItems.push({ + type: 'group', + label: ( + + {section.label} + + ), + key: `group-${section.label}`, + children: convertToMenuItems(section.items), + }) + }) + + return menuItems + } + + return ( + + {header && } + + + + {userProfile && } + + ) +} + +export type { ISidebarHeaderProps, ISidebarUserProfileProps } diff --git a/src/components/POC/rokt-catalog/SidebarHeader.tsx b/src/components/POC/rokt-catalog/SidebarHeader.tsx new file mode 100644 index 0000000000..e7cfb07d9c --- /dev/null +++ b/src/components/POC/rokt-catalog/SidebarHeader.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Flex, Typography } from 'src/components' +import roktLogo from './rokt-logo.svg' +import { SizeSm, Margin, ColorTextSecondary } from 'src/styles/style' + +export interface ISidebarHeaderProps { + title: string + subtitle?: string + onClick?: () => void +} + +export function SidebarHeader({ title, subtitle, onClick }: ISidebarHeaderProps) { + return ( + + Rokt + + + {title} + + {subtitle && ( + {subtitle} + )} + + + ) +} diff --git a/src/components/POC/rokt-catalog/SidebarUserProfile.tsx b/src/components/POC/rokt-catalog/SidebarUserProfile.tsx new file mode 100644 index 0000000000..999bd492f7 --- /dev/null +++ b/src/components/POC/rokt-catalog/SidebarUserProfile.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react' +import { Avatar, Badge, Dropdown, Flex, Icon, Typography, type IMenuProps } from 'src/components' +import { + UserOutlined, + SettingOutlined, + QuestionCircleOutlined, + ShoppingOutlined, + SwapOutlined, + ShopOutlined, + FormOutlined, + LogoutOutlined, +} from '@ant-design/icons' +import { ColorBorder, PaddingSm } from 'src/styles/style' + +const { Text } = Typography + +export interface IUserMenuItem { + key: string + label: string + icon?: React.ReactNode + onClick?: () => void + danger?: boolean +} + +export interface ISidebarUserProfileProps { + name: string + email: string + avatarSrc?: string + avatarAlt?: string + isOnline?: boolean + onClick?: () => void + menuItems?: IUserMenuItem[] +} + +const defaultMenuItems: IUserMenuItem[] = [ + { key: 'profile', label: 'View & edit profile', icon: }, + { key: 'settings', label: 'Settings', icon: }, + { key: 'help', label: 'Help center', icon: }, + { key: 'test-order', label: 'Run a test order', icon: }, + { key: 'switch-accounts', label: 'Switch accounts', icon: }, + { key: 'switch-partner', label: 'Switch to Partner', icon: }, + { key: 'feedback', label: 'Give feedback', icon: }, + { key: 'signout', label: 'Sign out', icon: }, +] + +export function SidebarUserProfile({ + name, + email, + avatarSrc, + avatarAlt, + isOnline = true, + onClick, + menuItems = defaultMenuItems, +}: ISidebarUserProfileProps) { + const [open, setOpen] = useState(false) + + const dropdownItems: IMenuProps['items'] = menuItems.map(item => ({ + key: item.key, + label: item.label, + icon: item.icon, + danger: item.danger, + onClick: item.onClick, + })) + + const userProfileContent = ( + + + + + {!avatarSrc && name.charAt(0).toUpperCase()} + + + + {name} + {email} + + + + + + + + ) + + return ( + + {userProfileContent} + + ) +} diff --git a/src/components/POC/rokt-catalog/expanded-sidebar.css b/src/components/POC/rokt-catalog/expanded-sidebar.css new file mode 100644 index 0000000000..f95a0e8c4e --- /dev/null +++ b/src/components/POC/rokt-catalog/expanded-sidebar.css @@ -0,0 +1,47 @@ +/* Menu item padding overrides */ +.expandedSidebar__menu .ant-menu-item { + padding-left: var(--padding-sm) !important; + padding-right: var(--padding-sm) !important; + margin: 0 !important; +} + +.expandedSidebar__menu .ant-menu-submenu-title { + padding-left: var(--padding-sm) !important; + padding-right: var(--padding-sm) !important; + margin: 0 !important; +} + +.expandedSidebar__menu .ant-menu-item-group-title { + padding-left: var(--padding-sm) !important; + padding-right: var(--padding-sm) !important; + + /* Let Typography.Text handle font styles */ + font-size: inherit !important; + font-weight: inherit !important; + color: inherit !important; +} + +.expandedSidebar__menu .ant-menu-sub .ant-menu-item { + padding-left: calc(var(--padding-md) + 32px) !important; +} + +.expandedSidebar__menu .ant-menu-sub { + background: transparent !important; +} + +/* Ensure Sider children container fills height */ +.ant-layout-sider-children { + display: flex !important; + flex-direction: column !important; +} + +/* User profile styles */ +.expandedSidebar__userProfile { + margin-top: auto; +} + +.expandedSidebar__avatarWrapper { + position: relative; + flex-shrink: 0; +} + diff --git a/src/components/POC/rokt-catalog/rokt-logo.svg b/src/components/POC/rokt-catalog/rokt-logo.svg new file mode 100644 index 0000000000..a4163f2925 --- /dev/null +++ b/src/components/POC/rokt-catalog/rokt-logo.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/POC/untitled-ui-steps/ProgressSteps.stories.tsx b/src/components/POC/untitled-ui-steps/ProgressSteps.stories.tsx new file mode 100644 index 0000000000..332acced66 --- /dev/null +++ b/src/components/POC/untitled-ui-steps/ProgressSteps.stories.tsx @@ -0,0 +1,50 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import React, { useState } from 'react' +import { ProgressSteps } from './ProgressSteps' +import { createStepItem } from './StepItems' +import { PaddingLg } from 'src/styles/style' +import { Flex } from 'src/components' + +/** + * POC: Untitled UI Progress Steps visual style on Ant Design Steps + * + * Proving we can achieve https://www.untitledui.com/react/components/progress-steps + * look using https://5x.ant.design/components/steps with custom StepItem components + */ + +const meta: Meta = { + title: 'POC/ProgressSteps', + component: ProgressSteps, + parameters: { + layout: 'padded', + }, +} + +export default meta + +type Story = StoryObj + +export const DynamicSteps: Story = { + render: () => { + const [currentStep] = useState(1) + + const steps = [ + { title: 'Set up', id: 0 }, + { title: 'Build a report', id: 1 }, + { title: 'Schedule delivery', id: 2 }, + ] + + const items = steps.map(step => + createStepItem({ + title: step.title, + status: step.id < currentStep ? 'finish' : step.id === currentStep ? 'process' : 'wait', + }), + ) + + return ( + + + + ) + }, +} diff --git a/src/components/POC/untitled-ui-steps/ProgressSteps.tsx b/src/components/POC/untitled-ui-steps/ProgressSteps.tsx new file mode 100644 index 0000000000..3593fb7909 --- /dev/null +++ b/src/components/POC/untitled-ui-steps/ProgressSteps.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { Steps, type IStepsProps } from 'src/components' +import './progress-steps.css' + +export interface IProgressStepsProps extends IStepsProps {} + +export const ProgressSteps = (props: IProgressStepsProps) => { + return +} + +export default ProgressSteps diff --git a/src/components/POC/untitled-ui-steps/StepItems.tsx b/src/components/POC/untitled-ui-steps/StepItems.tsx new file mode 100644 index 0000000000..adb8b3f258 --- /dev/null +++ b/src/components/POC/untitled-ui-steps/StepItems.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { + SizeXxs, + ColorBorder, + ColorBgContainer, + SizeLg, + SizeXs, + ColorPrimary, + SizeXl, + MpBrandPrimary6, +} from 'src/styles/style' +import { Flex, Icon, Typography } from 'src/components' + +interface StepItemProps { + title: string + status?: 'finish' | 'process' | 'wait' +} + +export const FinishedStepItem = ({ title }: StepItemProps) => ({ + title: {title}, + icon: ( + + + + ), + status: 'finish' as const, +}) + +export const ProcessStepItem = ({ title }: StepItemProps) => ({ + title: ( + + {title} + + ), + icon: ( + <> +
+ +
+ + + ), + status: 'process' as const, +}) + +export const WaitStepItem = ({ title }: StepItemProps) => ({ + title: {title}, + icon: ( + +
+ + ), + status: 'wait' as const, +}) + +export const createStepItem = ({ title, status = 'wait' }: StepItemProps) => { + switch (status) { + case 'finish': + return FinishedStepItem({ title }) + case 'process': + return ProcessStepItem({ title }) + case 'wait': + default: + return WaitStepItem({ title }) + } +} diff --git a/src/components/POC/untitled-ui-steps/progress-steps.css b/src/components/POC/untitled-ui-steps/progress-steps.css new file mode 100644 index 0000000000..c7507f8233 --- /dev/null +++ b/src/components/POC/untitled-ui-steps/progress-steps.css @@ -0,0 +1,35 @@ +.untitled-ui-steps.ant-steps-vertical .ant-steps-item-icon { + margin-top: 0 !important; + margin-inline-end: var(--margin-sm) !important; + +} + +.untitled-ui-steps.ant-steps-vertical .ant-steps-item-content { + margin-top: 0 !important; + padding-top: 2px !important; /* Fine-tune vertical alignment */ +} + +.untitled-ui-steps.ant-steps-vertical .ant-steps-item-title { + line-height: 1.5 !important; + padding: 0 !important; +} + +/* Step spacing and connector line */ +.untitled-ui-steps.ant-steps-vertical .ant-steps-item-container { + min-height: 66px !important; +} + +.untitled-ui-steps.ant-steps-vertical > .ant-steps-item > .ant-steps-item-container > .ant-steps-item-tail { + padding: 28px 0 0 !important; + left: 11px !important; /* Center on 24px icon */ +} + +.untitled-ui-steps.ant-steps-vertical > .ant-steps-item > .ant-steps-item-container > .ant-steps-item-tail::after { + width: 2px !important; + background-color: var(--color-border) !important; +} + +.untitled-ui-steps.ant-steps-vertical > .ant-steps-item-finish > .ant-steps-item-container > .ant-steps-item-tail::after { + background-color: var(--mp-brand-primary-6) !important; +} + diff --git a/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.css b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.css new file mode 100644 index 0000000000..dabdcf1beb --- /dev/null +++ b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.css @@ -0,0 +1,121 @@ +.manage-columns-drawer .ant-drawer-body { + padding: 0; +} + +.manage-columns-content { + display: flex; + flex-direction: column; +} + +.manage-columns-description { + padding: var(--padding) var(--padding-lg) var(--padding-lg) var(--padding-lg); +} + +.manage-columns-section { + display: flex; + flex-direction: column; +} + +.manage-columns-section__header { + padding: var(--padding-sm) var(--padding-lg) var(--padding-xs) var(--padding-lg); + background-color: var(--mp-brand-secondary-2); + border-top: var(--line-width) solid var(--color-border-secondary); + border-bottom: var(--line-width) solid var(--color-border-secondary); +} + +.manage-columns-section__list { + display: flex; + flex-direction: column; +} + +.manage-columns-row { + display: flex; + align-items: center; + padding: var(--padding) var(--padding-lg); + gap: var(--padding-sm); + cursor: grab; + background-color: var(--color-bg-container); + border-bottom: var(--line-width) solid var(--color-border-secondary); + transition: background-color var(--motion-duration-mid) ease, box-shadow var(--motion-duration-mid) ease; +} + +.manage-columns-row:hover { + background-color: var(--control-item-bg-hover); +} + +.manage-columns-row:active { + cursor: grabbing; +} + +.manage-columns-row--dragging { + opacity: 0.5; + background-color: var(--color-fill-tertiary); +} + +.manage-columns-row--drag-over { + background-color: var(--color-primary-bg); + box-shadow: inset 0 calc(var(--line-width-bold) * -1) 0 0 var(--color-primary); +} + +.manage-columns-row__drag-handle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.manage-columns-row__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.manage-columns-row__actions { + flex-shrink: 0; +} + +.manage-columns-row__actions .ant-btn { + border: none !important; + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; +} + +.manage-columns-row__actions .ant-btn:hover, +.manage-columns-row__actions .ant-btn:focus, +.manage-columns-row__actions .ant-btn:active { + border: none !important; + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; +} + +.ant-dropdown-menu-item { + font-size: var(--font-size-sm); + padding: var(--padding-xs) var(--padding-sm); +} + +.ant-dropdown-menu-item .ant-dropdown-menu-item-icon { + margin-inline-end: var(--padding-sm) !important; +} + +.ant-dropdown-menu-item .ant-dropdown-menu-title-content { + font-size: var(--font-size-sm); +} + +.manage-columns-menu-item--remove:hover { + background-color: var(--color-error) !important; + color: var(--color-white) !important; +} + +.manage-columns-menu-item--remove:hover span { + color: var(--color-white) !important; +} + +.manage-columns-menu-item--remove:hover svg { + color: var(--color-white) !important; + fill: var(--color-white) !important; +} diff --git a/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.stories.tsx b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.stories.tsx new file mode 100644 index 0000000000..00c5938adb --- /dev/null +++ b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.stories.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Button, message } from 'src/components' +import { ManageColumnsDrawer, type IColumnItem } from './ManageColumnsDrawer' + +const meta: Meta = { + title: 'POC/ManageColumnsDrawer', + component: ManageColumnsDrawer, + parameters: { + layout: 'fullscreen', + }, +} + +export default meta +type Story = StoryObj + +const defaultDimensions: IColumnItem[] = [ + { id: 'day', label: 'Day' }, + { id: 'campaign_name', label: 'Campaign name' }, + { id: 'campaign_id', label: 'Campaign ID' }, +] + +const defaultMetrics: IColumnItem[] = [ + { id: 'gross_cost', label: 'Gross cost' }, + { id: 'impressions', label: 'Impressions' }, + { id: 'referrals', label: 'Referrals' }, + { id: 'conversion_rate', label: 'Conversion rate' }, + { id: 'conversions', label: 'Conversions' }, +] + +const DefaultTemplate = () => { + const [open, setOpen] = useState(false) + const [dimensions, setDimensions] = useState(defaultDimensions) + const [metrics, setMetrics] = useState(defaultMetrics) + + const handleRename = (columnId: string, type: 'dimension' | 'metric') => { + const items = type === 'dimension' ? dimensions : metrics + const item = items.find(i => i.id === columnId) + if (item) { + void message.info(`Rename "${item.label}" (${type})`) + } + } + + const handleRemove = (columnId: string, type: 'dimension' | 'metric') => { + if (type === 'dimension') { + setDimensions(prev => prev.filter(item => item.id !== columnId)) + } else { + setMetrics(prev => prev.filter(item => item.id !== columnId)) + } + void message.success('Column removed') + } + + return ( +
+ + + setOpen(false)} + dimensions={dimensions} + metrics={metrics} + onDimensionsChange={setDimensions} + onMetricsChange={setMetrics} + onRenameColumn={handleRename} + onRemoveColumn={handleRemove} + /> +
+ ) +} + +export const Default: Story = { + render: () => , +} diff --git a/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.tsx b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.tsx new file mode 100644 index 0000000000..61fb15f80a --- /dev/null +++ b/src/components/UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer.tsx @@ -0,0 +1,281 @@ +import React, { useState, useCallback } from 'react' +import { Drawer, Icon, MoreActionsButton, type IDrawerProps } from 'src/components' +import { Typography } from 'src/components/general/Typography/Typography' +import './ManageColumnsDrawer.css' +import { MarginXs, Padding, PaddingLg } from 'src/styles/style' + +export interface IColumnItem { + /** + * Unique identifier for the column + */ + id: string + /** + * Display label for the column + */ + label: string +} + +export interface IManageColumnsDrawerProps extends Omit { + /** + * Whether the drawer is open + */ + open: boolean + /** + * Handler called when the drawer is closed + */ + onClose: () => void + /** + * List of dimension columns + */ + dimensions: IColumnItem[] + /** + * List of metric columns + */ + metrics: IColumnItem[] + /** + * Handler called when dimensions are reordered + */ + onDimensionsChange: (dimensions: IColumnItem[]) => void + /** + * Handler called when metrics are reordered + */ + onMetricsChange: (metrics: IColumnItem[]) => void + /** + * Handler called when a column is renamed + */ + onRenameColumn?: (columnId: string, type: 'dimension' | 'metric') => void + /** + * Handler called when a column is removed + */ + onRemoveColumn?: (columnId: string, type: 'dimension' | 'metric') => void + /** + * Custom title for the drawer + */ + title?: string + /** + * Custom description text + */ + description?: string + /** + * Label for the dimensions section + */ + dimensionsLabel?: string + /** + * Label for the metrics section + */ + metricsLabel?: string + /** + * Width of the drawer + */ + width?: number +} + +interface IColumnRowProps { + item: IColumnItem + type: 'dimension' | 'metric' + index: number + onDragStart: (e: React.DragEvent, index: number, type: 'dimension' | 'metric') => void + onDragOver: (e: React.DragEvent, index: number) => void + onDragEnd: () => void + onDrop: (e: React.DragEvent, index: number, type: 'dimension' | 'metric') => void + isDragging: boolean + isDraggedOver: boolean + onRename?: () => void + onRemove?: () => void +} + +const ColumnRow: React.FC = ({ + item, + type, + index, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + isDragging, + isDraggedOver, + onRename, + onRemove, +}) => { + const menuItems = { + items: [ + onRename && { + key: 'rename', + icon: , + label: Rename, + onClick: onRename, + }, + onRemove && { + key: 'remove', + className: 'manage-columns-menu-item--remove', + icon: , + label: Remove, + onClick: onRemove, + }, + ].filter((item): item is NonNullable => Boolean(item)), + } + + const showMoreActions = onRename ?? onRemove + + return ( +
onDragStart(e, index, type)} + onDragOver={e => onDragOver(e, index)} + onDragEnd={onDragEnd} + onDrop={e => onDrop(e, index, type)}> +
+ +
+
+ {item.label} +
+ {showMoreActions && } +
+ ) +} + +export const ManageColumnsDrawer: React.FC = ({ + open, + onClose, + dimensions, + metrics, + onDimensionsChange, + onMetricsChange, + onRenameColumn, + onRemoveColumn, + title = 'Manage columns', + description = 'Reorder columns by dragging, or use the dropdown to rename or remove.', + dimensionsLabel = 'Current dimensions', + metricsLabel = 'Current metrics', + width = 440, + ...drawerProps +}) => { + const [dragState, setDragState] = useState<{ + draggedIndex: number | null + draggedType: 'dimension' | 'metric' | null + dragOverIndex: number | null + dragOverType: 'dimension' | 'metric' | null + }>({ + draggedIndex: null, + draggedType: null, + dragOverIndex: null, + dragOverType: null, + }) + + const handleDragStart = useCallback((e: React.DragEvent, index: number, type: 'dimension' | 'metric') => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', '') // Required for Firefox + setDragState(prev => ({ + ...prev, + draggedIndex: index, + draggedType: type, + })) + }, []) + + const handleDragOver = useCallback( + (e: React.DragEvent, index: number, type: 'dimension' | 'metric') => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + + if (dragState.draggedType === type) { + setDragState(prev => ({ + ...prev, + dragOverIndex: index, + dragOverType: type, + })) + } + }, + [dragState.draggedType], + ) + + const handleDragEnd = useCallback(() => { + setDragState({ + draggedIndex: null, + draggedType: null, + dragOverIndex: null, + dragOverType: null, + }) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, dropIndex: number, type: 'dimension' | 'metric') => { + e.preventDefault() + + const { draggedIndex, draggedType } = dragState + + if (draggedIndex === null || draggedType === null || draggedType !== type) { + handleDragEnd() + return + } + + if (draggedIndex === dropIndex) { + handleDragEnd() + return + } + + const items = type === 'dimension' ? [...dimensions] : [...metrics] + const [draggedItem] = items.splice(draggedIndex, 1) + items.splice(dropIndex, 0, draggedItem) + + if (type === 'dimension') { + onDimensionsChange(items) + } else { + onMetricsChange(items) + } + + handleDragEnd() + }, + [dragState, dimensions, metrics, onDimensionsChange, onMetricsChange, handleDragEnd], + ) + + const renderSection = (items: IColumnItem[], type: 'dimension' | 'metric', label: string) => ( +
+
+ + {label} + +
+
+ {items.map((item, index) => ( + handleDragOver(e, index, type)} + onDragEnd={handleDragEnd} + onDrop={handleDrop} + isDragging={dragState.draggedIndex === index && dragState.draggedType === type} + isDraggedOver={dragState.dragOverIndex === index && dragState.dragOverType === type} + onRename={onRenameColumn ? () => onRenameColumn(item.id, type) : undefined} + onRemove={onRemoveColumn ? () => onRemoveColumn(item.id, type) : undefined} + /> + ))} +
+
+ ) + + return ( + {title}} + open={open} + onClose={onClose} + width={width} + className="manage-columns-drawer" + {...drawerProps}> +
+ + {description} + + + {dimensions.length > 0 && renderSection(dimensions, 'dimension', dimensionsLabel)} + {metrics.length > 0 && renderSection(metrics, 'metric', metricsLabel)} +
+
+ ) +} diff --git a/src/components/UXPatterns/StatisticsCard/StatisticsCard.stories.tsx b/src/components/UXPatterns/StatisticsCard/StatisticsCard.stories.tsx new file mode 100644 index 0000000000..dc9373d721 --- /dev/null +++ b/src/components/UXPatterns/StatisticsCard/StatisticsCard.stories.tsx @@ -0,0 +1,57 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import { StatisticsCard } from './StatisticsCard' +import { Row, Col } from 'src/components' + +const meta: Meta = { + title: 'UX Patterns/StatisticsCard', + component: StatisticsCard, + parameters: { + layout: 'centered', + }, +} +export default meta + +type Story = StoryObj + +export const ModelMetrics: Story = { + render: () => { + const metrics = [ + { + title: 'Precision', + value: 72, + tooltip: + 'Precision focuses on how many of the users predicted to act actually do so. High precision means less wasted impressions or messaging.', + }, + { + title: 'Recall', + value: 76, + tooltip: + 'Recall shows how many of the actual converters the model successfully identifies. Higher recall means fewer missed opportunities.', + }, + { + title: 'Accuracy', + value: 87, + tooltip: + "Accuracy shows how often the model's predictions match what really happens, giving you confidence in your audience targeting or personalization strategy.", + }, + { + title: 'AUC', + value: 87, + tooltip: + 'AUC tells you how powerful your model is at separating likely converters from everyone else — the higher the AUC, the better your targeting precision.', + }, + ] + + return ( +
+ + {metrics.map(metric => ( + + + + ))} + +
+ ) + }, +} diff --git a/src/components/UXPatterns/StatisticsCard/StatisticsCard.tsx b/src/components/UXPatterns/StatisticsCard/StatisticsCard.tsx new file mode 100644 index 0000000000..a458c89c67 --- /dev/null +++ b/src/components/UXPatterns/StatisticsCard/StatisticsCard.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Card, Flex, Statistic, IconWithTooltip } from 'src/components' +import type { ReactNode } from 'react' +import { FontSizeHeading3, FontSizeLg, FontWeightStrong } from 'src/styles/style' + +export interface IStatisticsCardProps { + title: string + value: string | number + denominator?: string | number + tooltip?: ReactNode + valueFontSize?: string + denominatorFontSize?: string + valueFontWeight?: string | number +} + +export const StatisticsCard: React.FC = ({ + title, + value, + denominator = '100', + tooltip, + valueFontSize = FontSizeHeading3, + denominatorFontSize = FontSizeLg, + valueFontWeight = FontWeightStrong, +}) => { + return ( + + + ( + <> + {value} + / {denominator} + + )} + /> + {tooltip && } + + + ) +} diff --git a/src/components/UXPatterns/Steps/Steps.stories.tsx b/src/components/UXPatterns/Steps/Steps.stories.tsx new file mode 100644 index 0000000000..82b9708663 --- /dev/null +++ b/src/components/UXPatterns/Steps/Steps.stories.tsx @@ -0,0 +1,41 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import { Center } from 'src/components' +import { Steps } from 'src/components/navigation/Steps/Steps' + +const items = [ + { + title: 'Define request', + description: 'Gather requirements', + }, + { + title: 'Review', + description: 'Validate with stakeholders', + }, + { + title: 'Approve', + description: 'Get program sign-off', + }, +] + +const meta: Meta = { + title: 'UX Patterns/Steps', + component: Steps, + parameters: { + layout: 'centered', + }, +} +export default meta + +type Story = StoryObj + +export const UXPatternExample: Story = { + render: () => { + const currentStep = 3 + + return ( +
+ +
+ ) + }, +} diff --git a/src/components/data-display/Avatar/Avatar.stories.tsx b/src/components/data-display/Avatar/Avatar.stories.tsx index 634b377842..f7621364e6 100644 --- a/src/components/data-display/Avatar/Avatar.stories.tsx +++ b/src/components/data-display/Avatar/Avatar.stories.tsx @@ -1,5 +1,10 @@ import { type Meta, type StoryObj } from '@storybook/react' -import { Avatar, Icon } from 'src/components' +import { Avatar, Badge, Icon, Space, type IIconProps } from 'src/components' +import { MpBrandSecondary3, MpBrandSecondary4, MpBrandSecondary8, Size } from 'src/styles/style' + +const neutralAvatarStyle = { backgroundColor: MpBrandSecondary4, color: MpBrandSecondary8 } +const neutralIconProps: IIconProps = { name: 'userProfiles', size: 'lg' } +const spaceSize = parseInt(Size, 10) const meta: Meta = { title: 'Components/Data Display/Avatar', @@ -14,14 +19,110 @@ type Story = StoryObj Customize the stories based on specific requirements. */ -export const WithInitials: Story = { +export const ShapeExamples: Story = { + render: () => { + return ( + + MP + + MP + + + ) + }, +} + +export const SizeExamples: Story = { + render: () => { + return ( + + + MP + + MP + + MP + + + ) + }, +} + +export const TypePhoto: Story = { args: { - children: 'mP', + src: 'https://randomuser.me/api/portraits/women/72.jpg', + alt: 'Avatar with photo', }, } -export const WithIcon: Story = { +export const TypeIcon: Story = { args: { - icon: , + style: neutralAvatarStyle, + icon: , + }, +} + +export const TypeInitials: Story = { + args: { + children: 'MP', + }, +} + +export const WithBadge: Story = { + render: () => { + return ( + + + } /> + + + MP + + + ) + }, +} + +export const WithGroup: Story = { + render: () => { + const teammates = [ + { + name: 'Chloe Martinez', + src: 'https://randomuser.me/api/portraits/women/65.jpg', + }, + { + name: 'Ernest Wallace', + src: 'https://randomuser.me/api/portraits/men/34.jpg', + }, + { + name: 'Priya Patel', + initials: 'PP', + }, + { + name: 'Lars Ostergaard', + initials: 'LO', + }, + ] + + return ( + + + {teammates.map(teammate => { + const style = teammate.initials != null ? neutralAvatarStyle : undefined + + return ( + + {teammate.initials} + + ) + })} + + + ) }, } diff --git a/src/components/data-display/Avatar/Avatar.tsx b/src/components/data-display/Avatar/Avatar.tsx index d8bb0eb44f..63ff106166 100644 --- a/src/components/data-display/Avatar/Avatar.tsx +++ b/src/components/data-display/Avatar/Avatar.tsx @@ -1,6 +1,7 @@ import { Avatar as AntAvatar } from 'antd' import { type AvatarProps as AntAvatarProps } from 'antd' import { ConfigProvider } from 'src/components' +import './avatar.css' export interface IAvatarProps extends AntAvatarProps {} diff --git a/src/components/data-display/Avatar/avatar.css b/src/components/data-display/Avatar/avatar.css new file mode 100644 index 0000000000..b838cf564f --- /dev/null +++ b/src/components/data-display/Avatar/avatar.css @@ -0,0 +1,9 @@ +/* Remove transform for dots on circular avatars */ +.ant-badge:has(.ant-avatar-circle) .ant-badge-dot { + transform: none; +} + +.ant-badge:has(.ant-avatar-circle) .ant-badge-count:not(.ant-badge-dot) { + transform: translate(50%, -50%); +} + diff --git a/src/components/data-display/Badge/Badge.stories.tsx b/src/components/data-display/Badge/Badge.stories.tsx index 2016f743fb..4446b6fcac 100644 --- a/src/components/data-display/Badge/Badge.stories.tsx +++ b/src/components/data-display/Badge/Badge.stories.tsx @@ -1,8 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react' import { Badge } from 'src/components/data-display/Badge/Badge' -import { ColorPrimary } from 'src/styles/style' -import { ExampleStory } from 'src/utils/ExampleStory' import { Space } from 'src/components' +import { ColorPrimary, ColorSuccess, ColorWarning, ColorError, ColorBorderSecondary } from 'src/styles/style' const meta: Meta = { title: 'Components/Data Display/Badge', @@ -18,9 +17,22 @@ type Story = StoryObj */ export const DotBadge: Story = { - args: { - dot: true, - color: ColorPrimary, + render: () => { + const colors = [ + { label: 'Primary', value: ColorPrimary }, + { label: 'Success', value: ColorSuccess }, + { label: 'Warning', value: ColorWarning }, + { label: 'Error', value: ColorError }, + { label: 'Paused', value: ColorBorderSecondary }, + ] as const + + return ( + + {colors.map(({ label, value }) => ( + + ))} + + ) }, } @@ -33,23 +45,13 @@ export const StatusBadge: Story = { }, render: () => { return ( - - - - - - - - -
- - - - - - - -
+ + + + + + + ) }, } diff --git a/src/components/data-display/ImageCard/ImageCard.stories.tsx b/src/components/data-display/ImageCard/ImageCard.stories.tsx new file mode 100644 index 0000000000..9013753f0c --- /dev/null +++ b/src/components/data-display/ImageCard/ImageCard.stories.tsx @@ -0,0 +1,114 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import { ImageCard } from 'src/components/data-display/ImageCard/ImageCard' +import { useState } from 'react' +import { Space } from 'src/components' +import { ExampleStory } from 'src/utils/ExampleStory' + +const meta: Meta = { + title: 'POC/ImageCard', + component: ImageCard, + args: { + src: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + alt: 'Sample image', + size: 180, + selected: false, + loading: false, + title: 'Image Card', + description: 'This is a sample image card', + }, + argTypes: { + selected: { + control: 'boolean', + }, + loading: { + control: 'boolean', + }, + tagColor: { + control: 'select', + options: ['default', 'blue', 'green', 'red', 'orange', 'purple', 'gold'], + }, + }, +} +export default meta + +type Story = StoryObj + +export const MultipleCards: Story = { + render: () => { + const [selectedIds, setSelectedIds] = useState([]) + + const images = [ + { + id: 1, + src: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + tag: 'Live', + tagColor: 'green', + title: 'Product A', + description: 'First product option', + }, + { + id: 2, + src: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg', + title: 'Product B', + description: 'Second product option', + }, + { + id: 3, + src: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + tag: 'Live', + tagColor: 'green', + title: 'Product C', + description: 'Third product option', + }, + { + id: 4, + src: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg', + tag: 'Live', + tagColor: 'green', + title: 'Product D', + description: 'Fourth product option', + }, + { + id: 5, + src: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + title: 'Product E', + description: 'Fifth product option', + }, + { + id: 6, + src: 'https://gw.alipayobjects.com/zos/antfincdn/aPkFc8Sj7n/method-draw-image.svg', + tag: 'Live', + tagColor: 'green', + title: 'Product F', + description: 'Sixth product option', + }, + ] + + const toggleSelection = (id: number): void => { + setSelectedIds(prev => (prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])) + } + + return ( + + + {images.map(img => ( + { + toggleSelection(img.id) + }} + tag={img.tag} + tagColor={img.tagColor} + title={img.title} + description={img.description} + /> + ))} + + + ) + }, +} diff --git a/src/components/data-display/ImageCard/ImageCard.tsx b/src/components/data-display/ImageCard/ImageCard.tsx new file mode 100644 index 0000000000..0430dde9f8 --- /dev/null +++ b/src/components/data-display/ImageCard/ImageCard.tsx @@ -0,0 +1,167 @@ +import React from 'react' +import { Card } from 'src/components/data-display/Card/Card' +import { Image } from 'src/components/data-display/Image/Image' +import { Tag } from 'src/components/data-display/Tag/Tag' +import { Spin } from 'src/components/feedback/Spin/Spin' +import { CheckOutlined } from '@ant-design/icons' +import { BorderRadiusSm, ColorBgBase, PaddingSm, PaddingXs } from 'src/styles/style' +import { Flex } from 'src/components/layout/Flex/Flex' +import { Typography } from 'src/components/general/Typography/Typography' + +const ColorBeetroot = '#C20075' + +export interface IImageCardProps { + src: string + alt?: string + selected?: boolean + onChange?: (selected: boolean) => void + tag?: React.ReactNode | string + tagColor?: string + loading?: boolean + size?: number | string + title: string + description: string + className?: string + style?: React.CSSProperties + onClick?: (e: React.MouseEvent) => void + 'data-testid'?: string +} + +export const ImageCard = (props: IImageCardProps): React.JSX.Element => { + const { + src, + alt = 'Image', + selected = false, + onChange, + tag, + tagColor, + loading = false, + size, + title, + description, + className = '', + style, + onClick, + 'data-testid': dataTestId, + } = props + + const handleClick = (e: React.MouseEvent): void => { + onChange?.(!selected) + onClick?.(e) + } + + return ( + +
+ } + style={{ + boxShadow: selected ? `0 0 0 2px ${ColorBeetroot}` : undefined, + height: '100%', + ...style, + }} + /> + + {/* Clickable overlay */} +
+ + {loading && ( +
+ +
+ )} + + {selected && ( +
+
+ +
+
+ )} + + {tag && ( +
+ {typeof tag === 'string' ? ( + + +
+ {tag} + + + ) : ( + tag + )} +
+ )} +
+ + + + {title} + + + {description} + + + + ) +} diff --git a/src/components/data-display/Popover/Popover.stories.tsx b/src/components/data-display/Popover/Popover.stories.tsx index e32b580643..60dda19216 100644 --- a/src/components/data-display/Popover/Popover.stories.tsx +++ b/src/components/data-display/Popover/Popover.stories.tsx @@ -1,6 +1,9 @@ import { Popover, type IPopoverProps } from 'src/components/data-display/Popover/Popover' import { type Meta, type StoryObj } from '@storybook/react' import { Button } from 'src/components/general/Button/Button' +import { Tag } from 'src/components/data-display/Tag/Tag' +import { Typography } from 'src/components/general/Typography/Typography' +import { Flex } from 'src/components/layout/Flex/Flex' const meta: Meta = { title: 'Components/Data Display/Popover', @@ -38,3 +41,32 @@ export const Primary: Story = { }, render: PrimaryTemplate, } + +const PopoverWithLinksTemplate = (args: IPopoverProps) => { + const content = ( + + Link Example 1 + Link Example 2 + Link Example 3 + + ) + + return ( + <> + + {args.children ?? '3'} + + + ) +} + +export const ExampleWithLinks: Story = { + args: { trigger: 'click', children: '3' }, + argTypes: { + trigger: { + control: 'select', + options: ['click', 'hover', 'focus'], + }, + }, + render: PopoverWithLinksTemplate, +} diff --git a/src/components/data-display/Segmented/Segmented.stories.tsx b/src/components/data-display/Segmented/Segmented.stories.tsx index ec86d385f1..223944b641 100644 --- a/src/components/data-display/Segmented/Segmented.stories.tsx +++ b/src/components/data-display/Segmented/Segmented.stories.tsx @@ -1,14 +1,37 @@ import { type Meta, type StoryObj } from '@storybook/react' import { Segmented } from 'src/components/data-display/Segmented/Segmented' +const segmentedOptions = [ + { label: 'Daily', value: 'daily' }, + { label: 'Weekly', value: 'weekly' }, + { label: 'Monthly', value: 'monthly' }, +] + const meta: Meta = { title: 'Components/Not Prod Ready/Data Display/Segmented', component: Segmented, - args: {}, + args: { + options: segmentedOptions, + defaultValue: segmentedOptions[0]?.value, + size: 'middle', + }, + argTypes: { + onChange: { action: 'change' }, + }, } export default meta type Story = StoryObj -export const Primary: Story = {} +export const Small: Story = { + args: { + size: 'small', + }, +} + +export const Large: Story = { + args: { + size: 'large', + }, +} diff --git a/src/components/data-display/Tooltip/IconWithTooltip.tsx b/src/components/data-display/Tooltip/IconWithTooltip.tsx new file mode 100644 index 0000000000..b14baba56e --- /dev/null +++ b/src/components/data-display/Tooltip/IconWithTooltip.tsx @@ -0,0 +1,28 @@ +import { Flex } from 'src/components/layout/Flex/Flex' +import { Icon, type IIconProps } from 'src/components/general/Icon/Icon' +import { Tooltip, type ITooltipProps } from './Tooltip' +import type { IconNames } from 'src/types/icons' +import type { ReactNode } from 'react' + +export interface IIconWithTooltipProps extends Omit { + title: ReactNode + iconName?: IconNames + iconProps?: Omit +} + +export const IconWithTooltip = ({ title, iconName = 'help', iconProps, ...rest }: IIconWithTooltipProps) => { + const iconPropsWithDefaults: IIconProps = { + name: iconName, + size: 'sm', + color: 'default', + ...iconProps, + } + + return ( + + + + + + ) +} diff --git a/src/components/data-display/Tooltip/Tooltip.stories.tsx b/src/components/data-display/Tooltip/Tooltip.stories.tsx index a6549c6b58..1ccc8a3945 100644 --- a/src/components/data-display/Tooltip/Tooltip.stories.tsx +++ b/src/components/data-display/Tooltip/Tooltip.stories.tsx @@ -1,6 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react' -import { Flex, Tooltip, Typography } from 'src/components' +import { Flex, Tooltip, Typography, IconWithTooltip } from 'src/components' import { Button } from 'src/components' +import { MarginXxs } from 'src/styles/style' const meta: Meta = { title: 'Components/Data Display/Tooltip', @@ -69,7 +70,13 @@ export const WithLink: Story = { }, } -/* - Initial story templates generated by AI. - Customize the stories based on specific requirements. -*/ +export const TextWithIcon: Story = { + render: () => { + return ( + + Strength + + + ) + }, +} diff --git a/src/components/data-entry/Checkbox/Checkbox.stories.tsx b/src/components/data-entry/Checkbox/Checkbox.stories.tsx index 9ac395cea7..600970d6e0 100644 --- a/src/components/data-entry/Checkbox/Checkbox.stories.tsx +++ b/src/components/data-entry/Checkbox/Checkbox.stories.tsx @@ -1,7 +1,8 @@ import { type Meta } from '@storybook/react' import { type StoryObj } from '@storybook/react' -import { Checkbox } from 'src/components' +import { Checkbox, Tooltip, Typography, Flex } from 'src/components' import { useState } from 'react' +import { SizeSm, SizeXs, SizeXl } from 'src/styles/style' export type CheckboxValueType = string | number | boolean @@ -37,6 +38,144 @@ export default meta type Story = StoryObj +const featureOptions = [ + { label: 'Feature A', value: 'feature-a' }, + { label: 'Feature B', value: 'feature-b' }, + { label: 'Feature C', value: 'feature-c' }, +] + +const CheckboxStatesExample = () => { + const [isChecked, setIsChecked] = useState(false) + + return ( +
+ { + setIsChecked(event.target.checked) + }}> + Default (interactive) + + Checked + Indeterminate + Disabled + + Disabled and checked + +
+ ) +} + +const CheckboxGroupExample = () => { + const [selectedOptions, setSelectedOptions] = useState(['feature-a', 'feature-c']) + + const allValues = featureOptions.map(option => option.value) + const isAllSelected = selectedOptions.length === featureOptions.length + const isIndeterminate = selectedOptions.length > 0 && !isAllSelected + + return ( +
+ { + setSelectedOptions(event.target.checked ? allValues : []) + }}> + Select all features + +
+ { + setSelectedOptions(values) + }} + /> +
+
+ ) +} + +const preferenceOptions = [ + { + label: 'Product updates', + value: 'product-updates', + description: 'Important improvements, release notes, and early access invitations.', + }, + { + label: 'Webinars and events', + value: 'events', + description: 'Invitations to upcoming live sessions, workshops, and community meetups.', + }, + { + label: 'Security advisories', + value: 'security', + description: 'Critical notifications about security and compliance-related changes.', + }, +] + +const CheckboxListExample = () => { + const [selectedValues, setSelectedValues] = useState(['product-updates']) + + const toggleValue = (value: CheckboxValueType, shouldSelect: boolean) => { + setSelectedValues(previousValues => { + if (shouldSelect) { + return previousValues.includes(value) ? previousValues : [...previousValues, value] + } + + return previousValues.filter(existingValue => existingValue !== value) + }) + } + + return ( +
+ {preferenceOptions.map(option => { + const isChecked = selectedValues.includes(option.value) + return ( + { + toggleValue(option.value, event.target.checked) + }}> + + {option.label} + {option.description} + + + ) + })} +
+ ) +} + +const LongLegalCopyExample = () => { + const [checked, setChecked] = useState(true) + + return ( +
+ { + setChecked(event.target.checked) + }}> + + I agree to the terms, policies, and any other legal guidelines required to use this service, including matters + related to privacy, data usage, third-party tools, cookies, and future updates to the agreement. + + +
+ ) +} + +const DisabledWithTooltipExample = () => ( + + + Checkbox label + + +) + /* Initial story templates generated by AI. Customize the stories based on specific requirements. @@ -45,6 +184,69 @@ type Story = StoryObj export const Primary: Story = { args: { disabled: false, - children: 'Don’t show this message again', + children: 'Checkbox label', + }, +} + +export const StatesShowcase: Story = { + name: 'States showcase', + render: () => , + parameters: { + docs: { + description: { + story: 'Display the default, checked, indeterminate, and disabled states together for fast visual comparison.', + }, + }, + }, +} + +export const DisabledWithTooltip: Story = { + name: 'Disabled with tooltip', + render: () => , + parameters: { + docs: { + description: { + story: 'Wrap a disabled checkbox with a tooltip to explain why the option is unavailable.', + }, + }, + }, +} + +export const GroupSelection: Story = { + name: 'Group selection', + render: () => , + parameters: { + docs: { + description: { + story: + 'Use `Checkbox.Group` to render related choices. Combine it with a single Checkbox to create a “Select all” control when needed.', + }, + }, + }, +} + +export const MultipleOptions: Story = { + name: 'Multiple options', + render: () => , + parameters: { + docs: { + description: { + story: + 'Stack individual checkboxes to present detailed choices. Each checkbox can include supporting text to clarify what opting in entails.', + }, + }, + }, +} + +export const LongFormConsent: Story = { + name: 'Long form consent', + render: () => , + parameters: { + docs: { + description: { + story: + 'Use a single Checkbox with stacked typography for lengthy consent copy. Keep supporting text in a secondary color to maintain readability.', + }, + }, }, } diff --git a/src/components/data-entry/Checkbox/Checkbox.tsx b/src/components/data-entry/Checkbox/Checkbox.tsx index b1dd1ba91e..f7236efa68 100644 --- a/src/components/data-entry/Checkbox/Checkbox.tsx +++ b/src/components/data-entry/Checkbox/Checkbox.tsx @@ -1,11 +1,13 @@ import { Checkbox as AntCheckbox } from 'antd' import { type CheckboxProps as AntCheckboxProps } from 'antd' +import { type CheckboxGroupProps as AntCheckboxGroupProps } from 'antd/es/checkbox/Group' import { ConfigProvider } from 'src/components' import './checkbox.css' export interface ICheckboxProps extends AntCheckboxProps {} +export interface ICheckboxGroupProps extends AntCheckboxGroupProps {} -export const Checkbox = (props: ICheckboxProps) => { +const CheckboxComponent = (props: ICheckboxProps) => { return ( @@ -13,4 +15,12 @@ export const Checkbox = (props: ICheckboxProps) => { ) } -Checkbox.Group = AntCheckbox.Group +const CheckboxGroup = (props: ICheckboxGroupProps) => { + return ( + + + + ) +} + +export const Checkbox = Object.assign(CheckboxComponent, { Group: CheckboxGroup }) diff --git a/src/components/data-entry/Checkbox/checkbox.css b/src/components/data-entry/Checkbox/checkbox.css index ed954e67a6..7cef200a72 100644 --- a/src/components/data-entry/Checkbox/checkbox.css +++ b/src/components/data-entry/Checkbox/checkbox.css @@ -1,4 +1,10 @@ .ant-checkbox-wrapper { + display: inline-flex; + align-items: flex-start; font-weight: 400; } +.ant-checkbox { + align-self: flex-start; + margin-top: var(--margin-xxs); +} diff --git a/src/components/data-entry/DimensionPicker/DimensionPicker.stories.tsx b/src/components/data-entry/DimensionPicker/DimensionPicker.stories.tsx new file mode 100644 index 0000000000..1d10c4ce91 --- /dev/null +++ b/src/components/data-entry/DimensionPicker/DimensionPicker.stories.tsx @@ -0,0 +1,110 @@ +import { type Meta, type StoryObj } from '@storybook/react' +import { useState } from 'react' +import { DimensionPicker, type IDimensionCategory, type IDimensionItem } from './DimensionPicker' + +const sampleCategories: IDimensionCategory[] = [ + { key: 'campaign', label: 'Campaign', icon: 'flag' }, + { key: 'creative', label: 'Creative', icon: 'edit' }, + { key: 'geographic', label: 'Geographic', icon: 'placeholder' }, + { key: 'layout', label: 'Layout', icon: 'grid' }, + { key: 'page', label: 'Page', icon: 'openTab' }, + { key: 'partner', label: 'Partner', icon: 'organization' }, + { key: 'user', label: 'User', icon: 'user' }, + { key: 'additional', label: 'Additional Dimensions', icon: 'add' }, +] + +const sampleItems: IDimensionItem[] = [ + { + key: 'campaign-1', + label: 'Campaign ID', + categoryKey: 'campaign', + description: 'Unique identifier for the campaign.', + }, + { + key: 'campaign-2', + label: 'Campaign Name', + categoryKey: 'campaign', + description: 'The display name of the campaign.', + }, + { + key: 'campaign-3', + label: 'Campaign Status', + categoryKey: 'campaign', + description: 'Current status of the campaign (active, paused, completed).', + }, + { + key: 'creative-1', + label: 'Creative ID', + categoryKey: 'creative', + description: 'Unique identifier for the creative asset.', + }, + { + key: 'creative-2', + label: 'Creative Name', + categoryKey: 'creative', + description: 'The display name of the creative.', + }, + { key: 'geo-1', label: 'Country', categoryKey: 'geographic', description: 'The country where the user is located.' }, + { key: 'geo-2', label: 'Region', categoryKey: 'geographic', description: 'Geographic region or state.' }, + { key: 'geo-3', label: 'City', categoryKey: 'geographic', description: 'City where the user is located.' }, + { + key: 'layout-1', + label: 'Layout ID', + categoryKey: 'layout', + description: 'Unique identifier for the layout configuration.', + }, + { key: 'layout-2', label: 'Layout Name', categoryKey: 'layout', description: 'Display name of the layout.' }, + ...Array.from({ length: 15 }, (_, i) => ({ + key: `page-${i + 1}`, + label: i === 0 ? 'Dimension menu item lorem' : 'Lorem dimension menu item', + categoryKey: 'page', + description: + 'Mauris enim cursus tristique et consequat ultricies amet luctus. Interdum elementum amet nunc suspendisse nam malesuada augue.', + })), + { key: 'partner-1', label: 'Partner ID', categoryKey: 'partner', description: 'Unique identifier for the partner.' }, + { + key: 'partner-2', + label: 'Partner Name', + categoryKey: 'partner', + description: 'Display name of the partner organization.', + }, + { key: 'user-1', label: 'User ID', categoryKey: 'user', description: 'Unique identifier for the user.' }, + { key: 'user-2', label: 'User Segment', categoryKey: 'user', description: 'Audience segment the user belongs to.' }, + { + key: 'add-1', + label: 'Custom Dimension 1', + categoryKey: 'additional', + description: 'Custom dimension defined by the user.', + }, +] + +const meta: Meta = { + title: 'POC/DimensionPicker', + component: DimensionPicker, + parameters: { + layout: 'centered', + }, + args: { + categories: sampleCategories, + items: sampleItems, + }, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = { + render: () => { + const [selected, setSelected] = useState(['campaign-1']) + return ( + console.log('Applied:', keys)} + /> + ) + }, +} diff --git a/src/components/data-entry/DimensionPicker/DimensionPicker.tsx b/src/components/data-entry/DimensionPicker/DimensionPicker.tsx new file mode 100644 index 0000000000..18bdf99235 --- /dev/null +++ b/src/components/data-entry/DimensionPicker/DimensionPicker.tsx @@ -0,0 +1,237 @@ +import { useState, useMemo, type ReactNode } from 'react' +import { ConfigProvider, Input, Checkbox, Button, Empty, Typography } from 'src/components' +import { Icon } from 'src/components/general/Icon/Icon' +import type { IconNames } from 'src/types/icons' +import './dimension-picker.css' + +export interface IDimensionCategory { + /** Unique identifier for the category */ + key: string + /** Display label for the category */ + label: string + /** Icon name from the icon library */ + icon?: IconNames +} + +export interface IDimensionItem { + /** Unique identifier for the dimension item */ + key: string + /** Display label for the dimension */ + label: string + /** Category key this dimension belongs to */ + categoryKey: string + /** Optional description shown in the details panel */ + description?: string + /** Whether this item is disabled */ + disabled?: boolean +} + +export interface IDimensionPickerProps { + /** Title shown above the category list */ + categoryTitle?: string + /** Title shown above the description panel */ + descriptionTitle?: string + /** List of available categories */ + categories: IDimensionCategory[] + /** List of all dimension items */ + items: IDimensionItem[] + /** Currently selected dimension keys */ + value?: string[] + /** Default selected dimension keys (uncontrolled mode) */ + defaultValue?: string[] + /** Callback when selection changes */ + onChange?: (selectedKeys: string[]) => void + /** Callback when Apply button is clicked */ + onApply?: (selectedKeys: string[]) => void + /** Callback when Clear all button is clicked */ + onClearAll?: () => void + /** Placeholder text for the search input */ + searchPlaceholder?: string + /** Text for the Clear all button */ + clearAllText?: string + /** Text for the Apply button (count will be appended) */ + applyText?: string + /** Whether to show the Apply button */ + showApplyButton?: boolean + /** Whether to show the Clear all button */ + showClearAllButton?: boolean + /** Whether to show the description panel */ + showDescriptionPanel?: boolean + /** Custom empty state when no items match search */ + emptyContent?: ReactNode + /** Custom className for the container */ + className?: string + /** Loading state */ + loading?: boolean +} + +export const DimensionPicker = ({ + categoryTitle = 'Dimensions categories', + descriptionTitle = 'Description', + categories, + items, + value, + defaultValue = [], + onChange, + onApply, + onClearAll, + searchPlaceholder = 'Search', + clearAllText = 'Clear all', + applyText = 'Apply', + showApplyButton = true, + showClearAllButton = true, + showDescriptionPanel = true, + emptyContent, + className = '', + loading = false, +}: IDimensionPickerProps) => { + const [searchQuery, setSearchQuery] = useState('') + const [selectedCategory, setSelectedCategory] = useState(categories[0]?.key ?? '') + const [hoveredItem, setHoveredItem] = useState(null) + const [internalSelected, setInternalSelected] = useState(defaultValue) + + // Controlled vs uncontrolled + const selectedKeys = value ?? internalSelected + const setSelectedKeys = (keys: string[]) => { + if (value === undefined) { + setInternalSelected(keys) + } + onChange?.(keys) + } + + // Filter items by category and search + const filteredItems = useMemo(() => { + let filtered = items.filter(item => item.categoryKey === selectedCategory) + + if (searchQuery) { + const query = searchQuery.toLowerCase() + filtered = filtered.filter(item => item.label.toLowerCase().includes(query)) + } + + return filtered + }, [items, selectedCategory, searchQuery]) + + // Handle item selection + const handleItemToggle = (itemKey: string) => { + const newSelected = selectedKeys.includes(itemKey) + ? selectedKeys.filter(key => key !== itemKey) + : [...selectedKeys, itemKey] + setSelectedKeys(newSelected) + } + + // Handle clear all + const handleClearAll = () => { + setSelectedKeys([]) + onClearAll?.() + } + + // Handle apply + const handleApply = () => { + onApply?.(selectedKeys) + } + + const selectedCount = selectedKeys.length + + return ( + +
+ {/* Search Bar */} +
+ } + placeholder={searchPlaceholder} + value={searchQuery} + onChange={e => setSearchQuery(e.target.value)} + allowClear + /> +
+ + {/* Main Content */} +
+ {/* Categories Panel */} +
+ + {categoryTitle} + +
    + {categories.map(category => { + const isSelected = selectedCategory === category.key + return ( +
  • setSelectedCategory(category.key)}> + {category.icon && } + {category.label} +
  • + ) + })} +
+
+ + {/* Items Panel */} +
+ {loading ? ( +
+ Loading... +
+ ) : filteredItems.length === 0 ? ( +
+ {emptyContent ?? } +
+ ) : ( +
    + {filteredItems.map(item => { + const isChecked = selectedKeys.includes(item.key) + const isHovered = hoveredItem?.key === item.key + return ( +
  • setHoveredItem(item)} + onMouseLeave={() => setHoveredItem(null)}> + handleItemToggle(item.key)}> + {item.label} + +
  • + ) + })} +
+ )} +
+ + {/* Description Panel */} + {showDescriptionPanel && ( +
+ + {descriptionTitle} + + + {hoveredItem?.description ?? 'Hover over a dimension to see its description.'} + +
+ )} +
+ + {/* Footer */} + {(showClearAllButton || showApplyButton) && ( +
+ {showClearAllButton && ( + + )} + {showApplyButton && ( + + )} +
+ )} +
+
+ ) +} diff --git a/src/components/data-entry/DimensionPicker/dimension-picker.css b/src/components/data-entry/DimensionPicker/dimension-picker.css new file mode 100644 index 0000000000..ec9ff6ec03 --- /dev/null +++ b/src/components/data-entry/DimensionPicker/dimension-picker.css @@ -0,0 +1,213 @@ +.dimension-picker { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #eceae9; + border-radius: 8px; + min-width: 800px; + max-width: 1000px; + box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%); +} + +/* Search Bar */ +.dimension-picker__search { + padding: 8px 12px; + border-bottom: 1px solid #eceae9; +} + +.dimension-picker__search .ant-input-affix-wrapper { + background: #fff; + border-color: #eceae9; +} + +.dimension-picker__search .ant-input-affix-wrapper:hover, +.dimension-picker__search .ant-input-affix-wrapper:focus-within { + border-color: #c3aeff; +} + +/* Main Content */ +.dimension-picker__content { + display: flex; + flex: 1; + min-height: 400px; + max-height: 500px; +} + +/* Categories Panel */ +.dimension-picker__categories { + width: 220px; + border-right: 1px solid #eceae9; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.dimension-picker__categories-title { + display: block; + padding: 10px 12px 6px; +} + +.dimension-picker__categories-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; +} + +.dimension-picker__category-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.dimension-picker__category-item:hover { + background-color: #FAFAFA; +} + +.dimension-picker__category-item--selected { + background-color: #FAFAFA; +} + +.dimension-picker__category-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Items Panel */ +.dimension-picker__items { + flex: 1; + overflow-y: auto; + border-right: 1px solid #eceae9; + min-width: 280px; +} + +.dimension-picker__items-list { + list-style: none; + margin: 0; + padding: 4px 0; +} + +.dimension-picker__item { + padding: 8px 12px; + transition: background-color 0.15s ease; +} + +.dimension-picker__item:hover, +.dimension-picker__item--hovered { + background-color: #FAFAFA; +} + +.dimension-picker__item .ant-checkbox-wrapper { + width: 100%; +} + +.dimension-picker__item .ant-checkbox .ant-checkbox-inner { + border-color: #babbb5; + background-color: #fff; +} + +.dimension-picker__item .ant-checkbox-checked .ant-checkbox-inner { + background-color: #C20075; + border-color: #C20075; +} + +.dimension-picker__item .ant-checkbox:hover .ant-checkbox-inner, +.dimension-picker__item .ant-checkbox-wrapper:hover .ant-checkbox-inner { + border-color: #C20075; +} + +.dimension-picker__item .ant-checkbox-checked:hover .ant-checkbox-inner { + background-color: #D6008A; + border-color: #D6008A; +} + +.dimension-picker__item .ant-checkbox-wrapper:not(.ant-checkbox-wrapper-disabled):hover .ant-checkbox-checked:not(.ant-checkbox-disabled) .ant-checkbox-inner { + background-color: #D6008A; + border-color: #D6008A; +} + +.dimension-picker__loading, +.dimension-picker__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 40px 20px; +} + +/* Description Panel */ +.dimension-picker__description { + width: 260px; + padding: 10px 12px; + background-color: #fff; + flex-shrink: 0; +} + +.dimension-picker__description-title { + display: block; + margin-bottom: 12px; +} + +.dimension-picker__description-content { + margin-bottom: 0; +} + +/* Footer */ +.dimension-picker__footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid #eceae9; + background-color: #fff; + border-radius: 0 0 8px 8px; +} + +.dimension-picker__footer .ant-btn-text { + color: rgb(0 0 0 / 75%); +} + +.dimension-picker__footer .ant-btn-text:hover:not(:disabled) { + color: rgb(0 0 0 / 95%); + background-color: rgb(0 0 0 / 4%); +} + +.dimension-picker__footer .ant-btn-primary { + background-color: #C20075; + border-color: #C20075; +} + +.dimension-picker__footer .ant-btn-primary:hover:not(:disabled) { + background-color: #D6008A; + border-color: #D6008A; +} + +/* Scrollbar styling */ +.dimension-picker__categories-list::-webkit-scrollbar, +.dimension-picker__items::-webkit-scrollbar { + width: 6px; +} + +.dimension-picker__categories-list::-webkit-scrollbar-track, +.dimension-picker__items::-webkit-scrollbar-track { + background: transparent; +} + +.dimension-picker__categories-list::-webkit-scrollbar-thumb, +.dimension-picker__items::-webkit-scrollbar-thumb { + background-color: #dcdcd8; + border-radius: 3px; +} + +.dimension-picker__categories-list::-webkit-scrollbar-thumb:hover, +.dimension-picker__items::-webkit-scrollbar-thumb:hover { + background-color: #babbb5; +} + diff --git a/src/components/data-entry/RadioCard/RadioCard.stories.tsx b/src/components/data-entry/RadioCard/RadioCard.stories.tsx new file mode 100644 index 0000000000..1a20a3771d --- /dev/null +++ b/src/components/data-entry/RadioCard/RadioCard.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { RadioCards } from './RadioCard' +import { Center } from 'src/components' + +const meta: Meta = { + title: 'Components/Data Entry/RadioCard', + component: RadioCards, + parameters: { + layout: 'padded', + }, +} + +export default meta +type Story = StoryObj + +export const Vertical: Story = { + render: () => { + const [value, setValue] = useState('secret') + + return ( +
+ +
+ ) + }, +} + +export const Horizontal: Story = { + render: () => { + const [value, setValue] = useState('warehouse') + + return ( +
+ +
+ ) + }, +} + +export const Disabled: Story = { + render: () => { + const [value, setValue] = useState('standard') + + return ( +
+ +
+ ) + }, +} diff --git a/src/components/data-entry/RadioCard/RadioCard.tsx b/src/components/data-entry/RadioCard/RadioCard.tsx new file mode 100644 index 0000000000..d0d35e0b7a --- /dev/null +++ b/src/components/data-entry/RadioCard/RadioCard.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { Card } from 'src/components/data-display/Card/Card' +import { Tooltip } from 'src/components/data-display/Tooltip/Tooltip' +import { Typography } from 'src/components/general/Typography/Typography' +import { Flex } from 'src/components/layout/Flex/Flex' +import { Radio } from 'src/components/data-entry/Radio/Radio' +import { + ColorBorderSecondary, + ColorPrimary, + LineType, + LineWidth, + SizeXs, + Size, + OpacityLoading, + Padding, +} from 'src/styles/style' + +export interface RadioCardOption { + value: T + title: React.ReactNode + description?: React.ReactNode + disabled?: boolean + tooltipTitle?: React.ReactNode + testId?: string +} + +export interface RadioCardsProps { + options: Array> + value?: T + onChange?: (value: T) => void + disabled?: boolean + orientation?: 'vertical' | 'horizontal' + radioAlign?: 'start' | 'center' + gap?: number | string +} + +interface RadioCardProps { + value: string + title: React.ReactNode + description?: React.ReactNode + disabled?: boolean + checked?: boolean + onChange?: (value: string) => void + radioAlign?: 'start' | 'center' +} + +const RadioCard: React.FC = ({ + title, + description, + value, + checked = false, + onChange, + radioAlign = 'start', + disabled = false, +}) => { + const handleClick = () => { + if (!disabled && onChange) { + onChange(value) + } + } + + return ( + + + + + {title} + {description} + + + + ) +} + +export const RadioCards = ({ + options, + value, + onChange, + disabled = false, + orientation = 'vertical', + radioAlign = 'start', + gap = Size, +}: RadioCardsProps) => { + return ( + + {options.map(option => { + const isDisabled = disabled || option.disabled + const isSelected = value === option.value + const cardValue = String(option.value) + + const card = ( + onChange?.(option.value)} + radioAlign={radioAlign} + disabled={isDisabled} + /> + ) + + return ( + + {option.tooltipTitle ? {card} : card} + + ) + })} + + ) +} diff --git a/src/components/feedback/Message/MessageNotifications.stories.tsx b/src/components/feedback/Message/MessageNotifications.stories.tsx new file mode 100644 index 0000000000..aa19f85019 --- /dev/null +++ b/src/components/feedback/Message/MessageNotifications.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Message } from 'src/components/feedback/Message/Message' +import { Typography } from 'src/components/general/Typography/Typography' +import { Button } from 'src/components/general/Button/Button' +import { Flex } from 'src/components/layout/Flex/Flex' +import { ColorError, SizeXs, ColorWarning, ColorSuccess, MarginXxs, ColorTextSecondary } from 'src/styles/style' +import './message-notifications.css' + +const meta: Meta = { + title: 'POC/Message Notifications', + component: Message, +} + +export default meta + +type Story = StoryObj + +export const AllStates: Story = { + render: () => { + const showAllMessages = () => { + // Error + void Message.error({ + content: ( + + This project has been unpublished + + Removing all users has unpublished this project. Add users to republish. + + + + ), + duration: 0, + className: 'message-notification-custom message-notification-error', + }) + + // Warning + setTimeout(() => { + void Message.warning({ + content: ( + + This project has been unpublished + + Removing all users has unpublished this project. Add users to republish. + + + + ), + duration: 0, + className: 'message-notification-custom message-notification-warning', + }) + }, 100) + + // Success + setTimeout(() => { + void Message.success({ + content: ( + + Successfully updated profile + + Your changes have been saved and your profile is live. Your team can make edits. + + + + + + + ), + duration: 0, + className: 'message-notification-custom message-notification-success', + }) + }, 200) + } + + return + }, +} diff --git a/src/components/feedback/Message/message-notifications.css b/src/components/feedback/Message/message-notifications.css new file mode 100644 index 0000000000..2ea90b2a87 --- /dev/null +++ b/src/components/feedback/Message/message-notifications.css @@ -0,0 +1,29 @@ +/* Custom styling for message notifications */ +.ant-message .ant-message-notice-wrapper .message-notification-custom.ant-message-notice .ant-message-notice-content { + text-align: left; +} + +/* Set flex start on custom content child */ +.ant-message .ant-message-notice-wrapper .message-notification-custom.ant-message-notice .ant-message-notice-content > .ant-message-custom-content { + display: flex; + align-items: flex-start !important; +} + +/* Move icons down by 4px */ +.ant-message .ant-message-notice-wrapper .message-notification-custom.ant-message-notice .ant-message-custom-content .anticon { + margin-top: 4px; +} + +/* Background colors for different message types */ +.ant-message .ant-message-notice-wrapper .message-notification-error.ant-message-notice .ant-message-notice-content { + background-color: var(--color-error-bg); +} + +.ant-message .ant-message-notice-wrapper .message-notification-warning.ant-message-notice .ant-message-notice-content { + background-color: var(--color-warning-bg); +} + +.ant-message .ant-message-notice-wrapper .message-notification-success.ant-message-notice .ant-message-notice-content { + background-color: var(--color-success-bg); +} + diff --git a/src/components/general/Button/Button.stories.tsx b/src/components/general/Button/Button.stories.tsx index 3e435afd68..af5775a0d3 100644 --- a/src/components/general/Button/Button.stories.tsx +++ b/src/components/general/Button/Button.stories.tsx @@ -1,8 +1,9 @@ import { Button } from 'src/components/general/Button/Button' import { type Meta, type StoryObj } from '@storybook/react' import { userEvent } from '@storybook/test' -import { Divider, Flex, Icon, Typography, Tooltip } from 'src/components' -import React from 'react' +import { Alert, Dropdown, Flex, Icon, Typography, Tooltip } from 'src/components' +import type { MenuProps } from 'antd' +import { BorderRadiusLg, ColorBorderSecondary, ColorWhite, MarginMd, SizeXs } from 'src/styles/style' const meta: Meta = { title: 'Components/General/Button', @@ -36,6 +37,44 @@ const meta: Meta = { export default meta type Story = StoryObj +export const LinkUsageNote: Story = { + parameters: { + docs: { + source: { + state: 'hidden', + }, + }, + }, + render: () => { + return ( + + 💡 + + Use the{' '} + + Typography.Link + {' '} + when navigating to a new URL or external page. + + + } + /> + ) + }, +} /* Initial story templates generated by AI. @@ -45,7 +84,7 @@ type Story = StoryObj export const Primary: Story = { args: { type: 'primary', - children: 'Create', + children: 'Primary', }, play: async context => { const button = context.canvasElement.querySelector('button') @@ -61,14 +100,14 @@ export const PrimaryWithIcon: Story = { args: { type: 'primary', icon: , - children: 'Add', + children: 'Primary', }, } export const Default: Story = { args: { type: 'default', - children: 'Cancel', + children: 'Secondary', }, } @@ -76,13 +115,14 @@ export const DefaultWithIcon: Story = { args: { type: 'default', icon: , - children: 'View Columns', + children: 'Secondary', }, } export const Dashed: Story = { args: { type: 'dashed', + children: 'Tertiary', }, } @@ -90,14 +130,14 @@ export const DashedWithIcon: Story = { args: { type: 'dashed', icon: , - children: 'Connect Output', + children: 'Tertiary', }, } export const Link: Story = { args: { type: 'link', - children: 'Retry', + children: 'Link', }, } @@ -105,22 +145,105 @@ export const LinkWithIcon: Story = { args: { type: 'link', icon: , - children: 'Add Audience Criteria', + children: 'Link', + }, +} + +export const Text: Story = { + args: { + type: 'text', + children: 'Text', + }, +} + +export const Danger: Story = { + args: { + type: 'default', + danger: true, + children: 'Delete', + }, +} + +export const Loading: Story = { + args: { + type: 'primary', + loading: true, + children: 'Loading', + }, +} + +export const LoadingText: Story = { + args: { + type: 'text', + loading: true, + children: 'Load more', + }, +} + +export const TypesOverview: Story = { + render: () => { + return ( + + + + + + + + ) + }, +} + +export const IconTypesOverview: Story = { + render: () => { + return ( + + + + + + + + ) + }, +} + +export const SizeExamples: Story = { + render: () => { + return ( + + + + + + ) }, } export const IconOnly: Story = { render: () => { return ( - <> - - + + ) + }, +} diff --git a/src/components/general/Button/Button.tsx b/src/components/general/Button/Button.tsx index 12bd359da7..05b7088ba9 100644 --- a/src/components/general/Button/Button.tsx +++ b/src/components/general/Button/Button.tsx @@ -7,7 +7,7 @@ import { ConfigProvider } from 'src/components/other/ConfigProvider/ConfigProvid import { type ReactNode } from 'react' import './button.css' -export interface IButtonProps extends Omit { +export interface IButtonProps extends Omit { /** * @deprecated This variant is a temporary fix for new icons. * Use this variant only with new icons to align the icon and text centered. @@ -15,6 +15,10 @@ export interface IButtonProps extends Omit { */ variant?: 'with-new-icon' icon?: ReactNode + /** + * Color of the button text. Use 'inherit' to inherit from parent element. + */ + color?: 'inherit' | string } export const Button = (props: IButtonProps) => { const classMap = { @@ -30,11 +34,17 @@ export const Button = (props: IButtonProps) => { const extraClass = props.variant === 'with-new-icon' ? classMap['with-new-icon'] : '' - const { variant, ...restProps } = props + const { variant, color, style, ...restProps } = props + + const buttonStyle = color ? { ...style, color } : style return ( - + {props.children} diff --git a/src/components/general/Typography/colors.ts b/src/components/general/Typography/colors.ts index af807bcd0c..5ec6f2d7ed 100644 --- a/src/components/general/Typography/colors.ts +++ b/src/components/general/Typography/colors.ts @@ -28,13 +28,18 @@ export const TypographyColors = [ 'ColorTextLabel', 'ColorTextDescription', 'ColorTextLightSolid', + 'inherit', ] as const export type TypographyColor = (typeof TypographyColors)[number] export function getColorFromStyles(color: TypographyColor | string): string { - if (styles[color as TypographyColor]) { - return (styles as unknown as Record)[color as TypographyColor] + if (color === 'inherit') { + return 'inherit' + } + + if (styles[color as keyof typeof styles]) { + return (styles as unknown as Record)[color] } return color diff --git a/src/components/index.ts b/src/components/index.ts index 3019026766..4696120c7e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,9 +15,16 @@ export { } from './data-entry/Select/Select' export { Mentions, type IMentionsProps } from './not-prod-ready/Mentions/Mentions' export { Radio, type IRadioProps } from './data-entry/Radio/Radio' +export { RadioCards, type RadioCardsProps, type RadioCardOption } from './data-entry/RadioCard/RadioCard' export { ColorPicker, type IColorPickerProps } from './not-prod-ready/ColorPicker/ColorPicker' export { Slider, type ISliderProps } from './not-prod-ready/Slider/Slider' export { Cascader, type ICascaderProps } from './data-entry/Cascader/Cascader' +export { + DimensionPicker, + type IDimensionPickerProps, + type IDimensionCategory, + type IDimensionItem, +} from './data-entry/DimensionPicker/DimensionPicker' export { DatePicker, type IDatePickerProps } from './data-entry/DatePicker/DatePicker' export { Checkbox, type ICheckboxProps } from './data-entry/Checkbox/Checkbox' export { Input, type IInputProps, type InputRef } from './data-entry/Input/Input' @@ -41,12 +48,14 @@ export { Tag, type ITagProps } from './data-display/Tag/Tag' export { Tour, type ITourProps } from './data-display/Tour/Tour' export { Carousel, type ICarouselProps } from './not-prod-ready/Carousel/Carousel' export { Tooltip, type ITooltipProps } from './data-display/Tooltip/Tooltip' +export { IconWithTooltip, type IIconWithTooltipProps } from './data-display/Tooltip/IconWithTooltip' export { Statistic, type IStatisticProps } from './not-prod-ready/Statistic/Statistic' export { Tree, type ITreeProps, type ITreeData } from './not-prod-ready/Tree/Tree' export { Image, type IImageProps } from './data-display/Image/Image' export { QRCode, type IQRCodeProps } from './not-prod-ready/QRCode/QRCode' export { Badge, type IBadgeProps } from './data-display/Badge/Badge' export { Card, type ICardProps } from './data-display/Card/Card' +export { ImageCard, type IImageCardProps } from './data-display/ImageCard/ImageCard' export { Avatar, type IAvatarProps } from './data-display/Avatar/Avatar' export { Descriptions, type IDescriptionsProps } from './data-display/Descriptions/Descriptions' export { @@ -155,10 +164,16 @@ export { type IMoreActionsButtonProps, type IMoreActionsButtonItem, } from './UXPatterns/MoreActionsButton/MoreActionsButton' +export { StatisticsCard, type IStatisticsCardProps } from './UXPatterns/StatisticsCard/StatisticsCard' export { UnauthorizedTooltip, type IUnauthorizedTooltipProps, } from './UXPatterns/PermissionsRestrictions/UnauthorizedTooltip' +export { + ManageColumnsDrawer, + type IManageColumnsDrawerProps, + type IColumnItem, +} from './UXPatterns/ManageColumnsDrawer/ManageColumnsDrawer' export { Utils } from '../shared/Utils' export { RoutesAuthorizationsService } from '../shared/services/RoutesAuthorizationsService'