Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): toggle component and more #1913

Merged
merged 6 commits into from
Nov 19, 2024
Merged
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/spicy-rings-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@penumbra-zone/ui': minor
---

- Add Toggle UI component
- Add `medium` density
- Add `xxs` Text style
- Improve the styles of `Tabs` component
51 changes: 28 additions & 23 deletions packages/ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -16,30 +16,35 @@ import '../src/theme/globals.css';
const DensityWrapper = ({ children, showDensityControl }) => {
const [density, setDensity] = useState('sparse');

return (
<ConditionalWrap
if={density === 'sparse'}
then={children => <Density sparse>{children}</Density>}
else={children => <Density compact>{children}</Density>}
>
<div className='flex flex-col gap-4'>
{showDensityControl && (
<Density sparse>
<Tabs
options={[
{ label: 'Sparse', value: 'sparse' },
{ label: 'Compact', value: 'compact' },
]}
value={density}
onChange={setDensity}
/>
</Density>
)}

{children}
</div>
</ConditionalWrap>
const densityTabs = (
<div className='flex flex-col gap-4'>
{showDensityControl && (
<Density sparse>
<Tabs
options={[
{ label: 'Sparse', value: 'sparse' },
{ label: 'Medium', value: 'medium' },
{ label: 'Compact', value: 'compact' },
]}
value={density}
onChange={setDensity}
/>
</Density>
)}

{children}
</div>
);

if (density === 'medium') {
return <Density medium>{densityTabs}</Density>;
}

if (density === 'compact') {
return <Density compact>{densityTabs}</Density>;
}

return <Density sparse>{densityTabs}</Density>;
};

const preview: Preview = {
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -44,6 +44,7 @@
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"clsx": "^2.1.1",
"lucide-react": "^0.378.0",
54 changes: 33 additions & 21 deletions packages/ui/src/Density/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { ReactNode } from 'react';
import { Density as TDensity, DensityContext } from '../utils/density';

export type DensityProps<SelectedDensity extends TDensity> = {
/**
* Utility interface to be used below to ensure that only one density type is used at a time.
*/
interface NeverDensityTypes {
sparse?: never;
medium?: never;
compact?: never;
}

export type DensityPropType =
| (Omit<NeverDensityTypes, 'sparse'> & {
sparse: true;
})
| (Omit<NeverDensityTypes, 'medium'> & {
medium: true;
})
| (Omit<NeverDensityTypes, 'compact'> & {
compact: true;
});

export type DensityProps = DensityPropType & {
children?: ReactNode;
} & (SelectedDensity extends 'sparse'
? { sparse: true; compact?: never }
: { compact: true; sparse?: never });
};
Comment on lines +9 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: can't we do this?

export type Density = 'sparse' | 'medium' | 'compact';

export type DensityProps = {
  density: Density;
  children?: ReactNode;
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though if we specifically need those names as a field, we could also do this:

export type DensityPropType =
  | { sparse: true; medium?: never; compact?: never }
  | { medium: true; sparse?: never; compact?: never }
  | { compact: true; sparse?: never; medium?: never };

export type DensityProps = DensityPropType & {
  children?: ReactNode;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Density and Text components initially had this style of props that is more neat: <Density compact/>, <Text body/>. I personally don't mind the union type but it is already a bit too late to change – so many components depend on a shorter syntax.

Changed the types to the version from your second comment


/**
* Use the `<Density />` component to set the density for all descendants in the
@@ -21,9 +39,9 @@ export type DensityProps<SelectedDensity extends TDensity> = {
* which contain nested components with density variants. If we used a `density`
* prop, you'd need to set that prop on every single component in that tree.
*
* Instead, you can simply wrap the entire `<Table />` with `<Density sparse />`
* or `<Density compact />`, and it will set a density context value for all
* descendant components:
* Instead, you can simply wrap the entire `<Table />` with `<Density sparse />`,
* `<Density medium />` or `<Density compact />`, and it will set a density context value
* for all descendant components:
*
* ```tsx
* <Density compact>
@@ -37,19 +55,15 @@ export type DensityProps<SelectedDensity extends TDensity> = {
* </Density>
* ```
*
* Components that support density variants are recognizable because the use the
* Components that support density variants are recognizable because they use the
* `useDensity()` hook, and then style their elements based on the value they
* receive from that hook:
*
* ```tsx
* const SomeStyledComponent = styled.div<{ $density: Density }>`
* padding: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)};
* `
*
* const MyComponent = () => {
* const density = useDensity();
*
* return <SomeStyledComponent $density={density} />
* return <div className={density === 'sparse' ? 'p-4' : 'p-1' } />
* }
* ```
*
@@ -72,11 +86,9 @@ export type DensityProps<SelectedDensity extends TDensity> = {
* />
* ```
*/
export const Density = <SelectedDensity extends TDensity>({
children,
sparse,
}: DensityProps<SelectedDensity>) => (
<DensityContext.Provider value={sparse ? 'sparse' : 'compact'}>
{children}
</DensityContext.Provider>
);
export const Density = ({ children, sparse, medium, compact }: DensityProps) => {
const density: TDensity =
(sparse && 'sparse') ?? (medium && 'medium') ?? (compact && 'compact') ?? 'sparse';

return <DensityContext.Provider value={density}>{children}</DensityContext.Provider>;
};
67 changes: 38 additions & 29 deletions packages/ui/src/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { tab, tabSmall } from '../utils/typography';
import { buttonBase, getOverlays } from '../utils/button';
import * as RadixTabs from '@radix-ui/react-tabs';
import { ActionType } from '../utils/action-type';
import { useDensity } from '../utils/density';
import cn from 'clsx';
import { tab, tabMedium, tabSmall } from '../utils/typography';
import { ActionType, getFocusOutlineColorByActionType } from '../utils/action-type';
import { Density, useDensity } from '../utils/density';

type LimitedActionType = Exclude<ActionType, 'destructive'>;

@@ -27,6 +26,23 @@ const getBorderColor = (actionType: LimitedActionType): string => {
return cn('border-action-neutralFocusOutline');
};

const getDensityClasses = (density: Density): string => {
if (density === 'compact') {
return cn('h-7');
}
return cn('h-[44px]');
};

const getDensityItemClasses = (density: Density): string => {
if (density === 'medium') {
return cn(tabMedium, 'grow shrink basis-0 p-2');
}
if (density === 'compact') {
return cn(tabSmall, 'py-1 px-2');
}
return cn(tab, 'grow shrink basis-0 p-2');
};

export interface TabsTab {
value: string;
label: string;
@@ -64,12 +80,7 @@ export const Tabs = ({ value, onChange, options, actionType = 'default' }: TabsP
return (
<RadixTabs.Root value={value} onValueChange={onChange}>
<RadixTabs.List asChild>
<div
className={cn(
'flex items-stretch box-border gap-4',
density === 'sparse' ? 'h-[44px]' : 'h-7',
)}
>
<div className={cn(getDensityClasses(density), 'flex items-stretch box-border gap-4')}>
{options.map(option => (
<RadixTabs.Trigger
value={option.value}
@@ -81,27 +92,25 @@ export const Tabs = ({ value, onChange, options, actionType = 'default' }: TabsP
onClick={() => onChange(option.value)}
disabled={option.disabled}
className={cn(
buttonBase,
getOverlays({ actionType, density }),
'h-full relative whitespace-nowrap text-text-primary',
density === 'sparse'
? cn(tab, 'grow shrink basis-0 p-2')
: cn(tabSmall, 'py-1 px-2'),
'before:rounded-tl-xs before:rounded-tr-xs before:rounded-bl-none before:rounded-br-none',
'focus-within:outline-none',
'after:inset-[2px]',
'appearance-none border-none text-inherit cursor-pointer',
'h-full relative whitespace-nowrap rounded-t-xs',
'transition-[background-color,outline-color,color] duration-150',
value === option.value ? 'text-text-primary' : 'text-text-secondary',
getDensityItemClasses(density),
getFocusOutlineColorByActionType(actionType),
'focus:outline focus:outline-2',
'hover:bg-action-hoverOverlay',
)}
>
{value === option.value && (
<div
className={cn(
'absolute inset-0 -z-[1]',
'border-b-2 border-solid',
getIndicatorColor(actionType),
getBorderColor(actionType),
)}
/>
)}
<div
className={cn(
value === option.value ? 'opacity-100' : 'opacity-0',
'absolute inset-0 transition-opacity pointer-events-none',
'border-b-2 border-solid',
getIndicatorColor(actionType),
getBorderColor(actionType),
)}
/>
{option.label}
</button>
</RadixTabs.Trigger>
4 changes: 4 additions & 0 deletions packages/ui/src/Text/index.tsx
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import {
xxl,
p,
getTextBase,
xxs,
} from '../utils/typography';
import { ElementType, ReactNode } from 'react';
import { ThemeColor } from '../utils/color';
@@ -180,6 +181,9 @@ export const Text = (props: TextProps) => {
if (props.detail) {
return <SpanElement className={cn(detail, classes)}>{props.children}</SpanElement>;
}
if (props.xxs) {
return <SpanElement className={cn(xxs, classes)}>{props.children}</SpanElement>;
}
if (props.small) {
return <SpanElement className={cn(small, classes)}>{props.children}</SpanElement>;
}
10 changes: 10 additions & 0 deletions packages/ui/src/Text/types.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ interface NeverTextTypes {
p?: never;
strong?: never;
detail?: never;
xxs?: never;
small?: never;
detailTechnical?: never;
technical?: never;
@@ -93,6 +94,15 @@ export type TextType =
*/
detail: true;
})
| (Omit<NeverTextTypes, 'xxs'> & {
/**
* xxs text used for extra small bits of tertiary information.
*
* Renders a `<span />` by default; pass the `as` prop to use a different
* HTML element with the same styling.
*/
xxs: true;
})
| (Omit<NeverTextTypes, 'small'> & {
/**
* Small text used for secondary information.
28 changes: 28 additions & 0 deletions packages/ui/src/Toggle/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useArgs } from '@storybook/preview-api';

import { Toggle } from '.';

const meta: Meta<typeof Toggle> = {
component: Toggle,
tags: ['autodocs', '!dev', 'density'],
};
export default meta;

type Story = StoryObj<typeof Toggle>;

export const Basic: Story = {
args: {
value: false,
label: 'Label',
disabled: false,
},

render: function Render(props) {
const [, updateArgs] = useArgs();

const onChange = (value: boolean) => updateArgs({ value });

return <Toggle {...props} onChange={onChange} />;
},
};
39 changes: 39 additions & 0 deletions packages/ui/src/Toggle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import cn from 'clsx';
import * as RadixToggle from '@radix-ui/react-toggle';
import { useDisabled } from '../utils/disabled-context';
import { useDensity } from '../utils/density';

export interface ToggleProps {
/** An accessibility label. */
label: string;
value: boolean;
onChange: (value: boolean) => void;
/** @todo: Implement disabled state visually. */
disabled?: boolean;
}

export const Toggle = ({ label, value, onChange, disabled }: ToggleProps) => {
const density = useDensity();

return (
<RadixToggle.Root
aria-label={label}
pressed={value}
onPressedChange={onChange}
disabled={useDisabled(disabled)}
className={cn(
'border border-solid border-other-tonalStroke rounded-full transition-colors cursor-pointer',
value ? 'bg-primary-main' : 'bg-base-transparent',
density === 'sparse' ? 'w-12' : 'w-8',
)}
>
<div
className={cn(
'rounded-full transition-all',
value ? 'bg-primary-contrast translate-x-[90%]' : 'bg-neutral-light translate-x-0',
density === 'sparse' ? 'size-6' : 'size-4',
)}
/>
</RadixToggle.Root>
);
};
2 changes: 2 additions & 0 deletions packages/ui/src/theme/theme.ts
Original file line number Diff line number Diff line change
@@ -253,6 +253,7 @@ export const theme = {
textBase: '1rem',
textSm: '0.875rem',
textXs: '0.75rem',
textXxs: '0.6875rem',
},
lineHeight: {
text9xl: '8.25rem',
@@ -268,6 +269,7 @@ export const theme = {
textBase: '1.5rem',
textSm: '1.25rem',
textXs: '1rem',
textXxs: '1rem',
},
spacing,
zIndex: {
2 changes: 1 addition & 1 deletion packages/ui/src/utils/density.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import { createContext, useContext } from 'react';
*
* See `<DensityContext />`
*/
export type Density = 'compact' | 'sparse';
export type Density = 'compact' | 'sparse' | 'medium';

/**
* This context is used internally by the `<Density />` component and the
Loading