mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add link variant for Button and ActionIcon (#1938)
## Summary - Adds a new `variant="link"` for `Button` and `ActionIcon` that renders as a plain text link — no background, no border, no padding. Text is muted by default and brightens on hover (adapts to light/dark mode). - Adds a **Brand** theme switcher to the Storybook toolbar so stories can be previewed in both HyperDX and ClickStack themes. - Documents the new variant in `code_style.md` and adds comprehensive Storybook stories. ## Changes | File | What | |------|------| | `themes/hyperdx/mantineTheme.ts` | `link` variant for Button (`vars` + `classNames`) and ActionIcon (`vars` + `classNames`) | | `themes/clickstack/mantineTheme.ts` | Same for ClickStack theme | | `styles/variants.module.scss` | Hover color transition and transparent disabled state for link variants | | `stories/Button.stories.tsx` | Link variant in CustomVariants, DisabledStates, LoadingStates | | `stories/ActionIcon.stories.tsx` | Link variant in CustomVariants, DisabledStates, LoadingStates | | `.storybook/preview.tsx` | Brand theme switcher (HyperDX / ClickStack) in toolbar | | `agent_docs/code_style.md` | Documented link variant usage and guidelines | ## Test plan - [ ] Verify `variant="link"` renders without background/border/padding in Storybook - [ ] Verify hover brightens text in both light and dark modes - [ ] Verify disabled state shows reduced opacity with no background - [ ] Switch brand theme in Storybook toolbar and confirm both HyperDX and ClickStack render correctly Made with [Cursor](https://cursor.com)
This commit is contained in:
parent
69cf33cbe6
commit
72d4642b6e
8 changed files with 202 additions and 9 deletions
5
.changeset/link-button-variant.md
Normal file
5
.changeset/link-button-variant.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add `link` variant for Button and ActionIcon components
|
||||
|
|
@ -36,6 +36,7 @@ The project uses Mantine UI with **custom variants** defined in `packages/app/sr
|
|||
| `variant="primary"` | Primary actions (Submit, Save, Create, Run) | `<Button variant="primary">Save</Button>` |
|
||||
| `variant="secondary"` | Secondary actions (Cancel, Clear, auxiliary actions) | `<Button variant="secondary">Cancel</Button>` |
|
||||
| `variant="danger"` | Destructive actions (Delete, Remove, Rotate API Key) | `<Button variant="danger">Delete</Button>` |
|
||||
| `variant="link"` | Link-style actions with no background or border (View Details, navigation-style CTAs) | `<Button variant="link">View Details</Button>` |
|
||||
|
||||
### DO NOT USE (Forbidden Patterns)
|
||||
|
||||
|
|
@ -58,11 +59,15 @@ The following patterns are **NOT ALLOWED** for Button and ActionIcon:
|
|||
<Button variant="primary">Save</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
<Button variant="danger">Delete</Button>
|
||||
<Button variant="link">View Details</Button>
|
||||
<ActionIcon variant="primary">...</ActionIcon>
|
||||
<ActionIcon variant="secondary">...</ActionIcon>
|
||||
<ActionIcon variant="danger">...</ActionIcon>
|
||||
<ActionIcon variant="link">...</ActionIcon>
|
||||
```
|
||||
|
||||
**Link variant details**: Renders with no background, no border, and muted text color. On hover, text brightens to full contrast. Use for link-style CTAs that should blend into surrounding content (e.g., "View Details", "View Full Trace").
|
||||
|
||||
**Note**: `variant="filled"` is still valid for **form inputs** (Select, TextInput, etc.), just not for Button/ActionIcon.
|
||||
|
||||
### Icon-Only Buttons → ActionIcon
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||
|
||||
import { ibmPlexMono, inter, roboto, robotoMono } from '../src/fonts';
|
||||
import { meHandler } from '../src/mocks/handlers';
|
||||
import { AppThemeProvider } from '../src/theme/ThemeProvider';
|
||||
import { ThemeName } from '../src/theme/types';
|
||||
import { ThemeWrapper } from '../src/ThemeWrapper';
|
||||
|
||||
import '@mantine/core/styles.css';
|
||||
|
|
@ -39,6 +41,19 @@ export const globalTypes = {
|
|||
],
|
||||
},
|
||||
},
|
||||
brand: {
|
||||
name: 'Brand',
|
||||
description: 'Brand theme',
|
||||
defaultValue: 'hyperdx',
|
||||
toolbar: {
|
||||
icon: 'paintbrush',
|
||||
title: 'Brand',
|
||||
items: [
|
||||
{ value: 'hyperdx', title: 'HyperDX' },
|
||||
{ value: 'clickstack', title: 'ClickStack' },
|
||||
],
|
||||
},
|
||||
},
|
||||
font: {
|
||||
name: 'Font',
|
||||
description: 'App font family',
|
||||
|
|
@ -79,23 +94,25 @@ const createQueryClient = () =>
|
|||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
// Create a fresh QueryClient for each story render
|
||||
const [queryClient] = React.useState(() => createQueryClient());
|
||||
|
||||
const selectedFont = context.globals.font || 'inter';
|
||||
const font = fontMap[selectedFont as keyof typeof fontMap] || inter;
|
||||
const fontFamily = font.style.fontFamily;
|
||||
const brandTheme = (context.globals.brand || 'hyperdx') as ThemeName;
|
||||
|
||||
return (
|
||||
<div className={font.className}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryParamProvider adapter={NextAdapter}>
|
||||
<ThemeWrapper
|
||||
colorScheme={context.globals.theme || 'light'}
|
||||
fontFamily={fontFamily}
|
||||
>
|
||||
<Story />
|
||||
</ThemeWrapper>
|
||||
<AppThemeProvider themeName={brandTheme}>
|
||||
<ThemeWrapper
|
||||
colorScheme={context.globals.theme || 'light'}
|
||||
fontFamily={fontFamily}
|
||||
>
|
||||
<Story />
|
||||
</ThemeWrapper>
|
||||
</AppThemeProvider>
|
||||
</QueryParamProvider>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
|
|||
import {
|
||||
IconCheck,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
|
|
@ -19,7 +20,7 @@ const meta: Meta<typeof ActionIcon> = {
|
|||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'danger'],
|
||||
options: ['primary', 'secondary', 'danger', 'link'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
|
|
@ -109,6 +110,26 @@ export const CustomVariants = () => (
|
|||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Link
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="link" size="sm">
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" size="md">
|
||||
<IconExternalLink size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" size="lg">
|
||||
<IconExternalLink size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" disabled>
|
||||
<IconExternalLink size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
|
@ -201,6 +222,26 @@ export const DisabledStates = () => (
|
|||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Link - Normal vs Disabled
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="link" size="md">
|
||||
<IconExternalLink size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" size="md" disabled>
|
||||
<IconExternalLink size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" size="lg">
|
||||
<IconExternalLink size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" size="lg" disabled>
|
||||
<IconExternalLink size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Subtle - Normal vs Disabled
|
||||
|
|
@ -269,6 +310,9 @@ export const LoadingStates = () => (
|
|||
<ActionIcon variant="danger" loading>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="link" loading>
|
||||
<IconExternalLink size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" loading>
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
|
|||
import {
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
|
|
@ -17,7 +18,7 @@ const meta: Meta<typeof Button> = {
|
|||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'danger'],
|
||||
options: ['primary', 'secondary', 'danger', 'link'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
|
|
@ -95,6 +96,21 @@ export const CustomVariants = () => (
|
|||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Link
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="link">Link</Button>
|
||||
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
|
||||
View Details
|
||||
</Button>
|
||||
<Button variant="link" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
|
@ -170,6 +186,28 @@ export const DisabledStates = () => (
|
|||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Link - Normal vs Disabled
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="link">Normal</Button>
|
||||
<Button variant="link" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button variant="link" rightSection={<IconExternalLink size={16} />}>
|
||||
With Icon
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
rightSection={<IconExternalLink size={16} />}
|
||||
disabled
|
||||
>
|
||||
Disabled with Icon
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
All Sizes - Disabled
|
||||
|
|
@ -215,6 +253,9 @@ export const LoadingStates = () => (
|
|||
<Button variant="danger" loading>
|
||||
Danger Loading
|
||||
</Button>
|
||||
<Button variant="link" loading>
|
||||
Link Loading
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
|
||||
import variantClasses from '../../../../styles/variants.module.scss';
|
||||
|
||||
/**
|
||||
* ClickStack Theme
|
||||
*
|
||||
|
|
@ -218,6 +220,12 @@ export const makeTheme = ({
|
|||
defaultProps: {
|
||||
variant: 'primary',
|
||||
},
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === 'link') {
|
||||
return { root: variantClasses.buttonLink };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
vars: (_theme, props) => {
|
||||
const baseVars: Record<string, string> = {};
|
||||
|
||||
|
|
@ -248,6 +256,14 @@ export const makeTheme = ({
|
|||
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
|
||||
}
|
||||
|
||||
if (props.variant === 'link') {
|
||||
baseVars['--button-bg'] = 'transparent';
|
||||
baseVars['--button-hover'] = 'transparent';
|
||||
baseVars['--button-color'] = 'var(--color-text-secondary)';
|
||||
baseVars['--button-bd'] = 'none';
|
||||
baseVars['--button-padding-x'] = '0';
|
||||
}
|
||||
|
||||
return { root: baseVars };
|
||||
},
|
||||
}),
|
||||
|
|
@ -273,6 +289,12 @@ export const makeTheme = ({
|
|||
variant: 'subtle',
|
||||
color: 'gray',
|
||||
},
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === 'link') {
|
||||
return { root: variantClasses.actionIconLink };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
vars: (_theme, props) => {
|
||||
const baseVars: Record<string, string> = {};
|
||||
|
||||
|
|
@ -308,6 +330,13 @@ export const makeTheme = ({
|
|||
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
|
||||
}
|
||||
|
||||
if (props.variant === 'link') {
|
||||
baseVars['--ai-bg'] = 'transparent';
|
||||
baseVars['--ai-hover'] = 'transparent';
|
||||
baseVars['--ai-color'] = 'var(--color-text-secondary)';
|
||||
baseVars['--ai-bd'] = 'none';
|
||||
}
|
||||
|
||||
return { root: baseVars };
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
|
||||
import focusClasses from '../../../../styles/focus.module.scss';
|
||||
import variantClasses from '../../../../styles/variants.module.scss';
|
||||
|
||||
export const makeTheme = ({
|
||||
fontFamily = '"IBM Plex Sans", monospace',
|
||||
|
|
@ -235,6 +236,12 @@ export const makeTheme = ({
|
|||
defaultProps: {
|
||||
variant: 'primary',
|
||||
},
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === 'link') {
|
||||
return { root: variantClasses.buttonLink };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
vars: (_theme, props) => {
|
||||
const baseVars: Record<string, string> = {};
|
||||
|
||||
|
|
@ -265,6 +272,14 @@ export const makeTheme = ({
|
|||
baseVars['--button-color'] = 'var(--mantine-color-red-light-color)';
|
||||
}
|
||||
|
||||
if (props.variant === 'link') {
|
||||
baseVars['--button-bg'] = 'transparent';
|
||||
baseVars['--button-hover'] = 'transparent';
|
||||
baseVars['--button-color'] = 'var(--color-text-secondary)';
|
||||
baseVars['--button-bd'] = 'none';
|
||||
baseVars['--button-padding-x'] = '0';
|
||||
}
|
||||
|
||||
return { root: baseVars };
|
||||
},
|
||||
}),
|
||||
|
|
@ -290,6 +305,12 @@ export const makeTheme = ({
|
|||
variant: 'subtle',
|
||||
color: 'gray',
|
||||
},
|
||||
classNames: (_theme, props) => {
|
||||
if (props.variant === 'link') {
|
||||
return { root: variantClasses.actionIconLink };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
vars: (_theme, props) => {
|
||||
const baseVars: Record<string, string> = {};
|
||||
|
||||
|
|
@ -325,6 +346,13 @@ export const makeTheme = ({
|
|||
baseVars['--ai-color'] = 'var(--mantine-color-red-light-color)';
|
||||
}
|
||||
|
||||
if (props.variant === 'link') {
|
||||
baseVars['--ai-bg'] = 'transparent';
|
||||
baseVars['--ai-hover'] = 'transparent';
|
||||
baseVars['--ai-color'] = 'var(--color-text-secondary)';
|
||||
baseVars['--ai-bd'] = 'none';
|
||||
}
|
||||
|
||||
return { root: baseVars };
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
24
packages/app/styles/variants.module.scss
Normal file
24
packages/app/styles/variants.module.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Hover: brighten text to full contrast.
|
||||
// Both Button and ActionIcon use SCSS for hover color because
|
||||
// Mantine only provides --button-color-hover (no ActionIcon equivalent).
|
||||
// Using SCSS for both keeps the approach consistent.
|
||||
.buttonLink:hover:not(:disabled, [data-disabled]) {
|
||||
--button-color: var(--color-text);
|
||||
}
|
||||
|
||||
.actionIconLink:hover:not(:disabled, [data-disabled]) {
|
||||
--ai-color: var(--color-text);
|
||||
}
|
||||
|
||||
// Disabled: override Mantine's built-in disabled background.
|
||||
// !important is required because Mantine applies disabled styles
|
||||
// via high-specificity internal selectors that CSS modules cannot beat.
|
||||
.buttonLink,
|
||||
.actionIconLink {
|
||||
&:disabled,
|
||||
&[data-disabled] {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue