mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-18 02:21:47 -05:00
Shade updates (#22045)
ref https://linear.app/ghost/issue/DES-1085/update-shade-to-be-used-in-activitypub - Shade so far was just used in our playground (Post analytics). It needed to be prepared so that it can be integrated in real projects like ActivityPub. This means cleaning up everything related to it like conventions, file structure, documentation etc.
This commit is contained in:
parent
568322c378
commit
e41fc2c4d5
26 changed files with 497 additions and 191 deletions
|
@ -1,18 +0,0 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
|
||||
import BoilerPlate from './boilerplate';
|
||||
|
||||
const meta = {
|
||||
title: 'Meta / Boilerplate',
|
||||
component: BoilerPlate,
|
||||
tags: ['autodocs']
|
||||
} satisfies Meta<typeof BoilerPlate>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BoilerPlate>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'This is a boilerplate component. Use as a basis to create new components.'
|
||||
}
|
||||
};
|
|
@ -1,15 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
interface BoilerPlateProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const BoilerPlate: React.FC<BoilerPlateProps> = ({children}) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoilerPlate;
|
35
apps/shade/src/components/ui/avatar.stories.tsx
Normal file
35
apps/shade/src/components/ui/avatar.stories.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Avatar, AvatarFallback, AvatarImage} from './avatar';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Avatar',
|
||||
component: Avatar,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
children: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Avatar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: <AvatarFallback>AG</AvatarFallback>
|
||||
}
|
||||
};
|
||||
|
||||
export const WithImage: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<AvatarImage src="https://avatars.githubusercontent.com/u/2178663?s=200&v=4" />
|
||||
<AvatarFallback>AG</AvatarFallback>
|
||||
</>
|
||||
)
|
||||
}
|
||||
};
|
17
apps/shade/src/components/ui/badge.stories.tsx
Normal file
17
apps/shade/src/components/ui/badge.stories.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Badge} from './badge';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs']
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Badge'
|
||||
}
|
||||
};
|
102
apps/shade/src/components/ui/chart.stories.tsx
Normal file
102
apps/shade/src/components/ui/chart.stories.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import type {Meta} from '@storybook/react';
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from './chart';
|
||||
import React from 'react';
|
||||
import {Label, Pie, PieChart} from 'recharts';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Charts',
|
||||
component: ChartContainer,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
children: {
|
||||
control: false
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof ChartContainer>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default = {
|
||||
render: function ChartStory() {
|
||||
const chartData = React.useMemo(() => {
|
||||
return [
|
||||
{browser: 'chrome', visitors: 98, fill: 'var(--color-chrome)'},
|
||||
{browser: 'safari', visitors: 17, fill: 'var(--color-safari)'}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: 'Reactions'
|
||||
},
|
||||
chrome: {
|
||||
label: 'More like this',
|
||||
color: 'hsl(var(--chart-1))'
|
||||
},
|
||||
safari: {
|
||||
label: 'Less like this',
|
||||
color: 'hsl(var(--chart-5))'
|
||||
}
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const totalVisitors = React.useMemo(() => {
|
||||
return chartData.reduce((acc, curr) => acc + curr.visitors, 0);
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartContainer
|
||||
className="mx-auto aspect-square h-[250px] min-h-[250px] w-full"
|
||||
config={chartConfig}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
cursor={false}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="visitors"
|
||||
innerRadius={60}
|
||||
nameKey="browser"
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({viewBox}) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
<tspan
|
||||
className="fill-foreground text-2xl font-semibold tracking-tight"
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
>
|
||||
{totalVisitors.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
className="fill-muted-foreground"
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
>
|
||||
Reactions
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className='text-center'>
|
||||
Visit <a className="underline" href="https://ui.shadcn.com/docs/components/chart" rel="noreferrer" target="_blank">ShadCN/UI Charts docs</a> for usage details.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
34
apps/shade/src/components/ui/dialog.stories.tsx
Normal file
34
apps/shade/src/components/ui/dialog.stories.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle} from './dialog';
|
||||
import {Button} from './button';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Dialog',
|
||||
component: Dialog,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
children: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Dialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Dialog>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<DialogTrigger className='cursor-pointer'><Button className='cursor-pointer'>Open</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
};
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import {cva, type VariantProps} from 'class-variance-authority';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {kebabToPascalCase} from '@/utils/formatText';
|
||||
import {cn, kebabToPascalCase} from '@/lib/utils';
|
||||
|
||||
const iconVariants = cva('', {
|
||||
variants: {
|
||||
|
|
15
apps/shade/src/components/ui/separator.stories.tsx
Normal file
15
apps/shade/src/components/ui/separator.stories.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Separator} from './separator';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Separator',
|
||||
component: Separator,
|
||||
tags: ['autodocs']
|
||||
} satisfies Meta<typeof Separator>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Separator>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {}
|
||||
};
|
50
apps/shade/src/components/ui/table.stories.tsx
Normal file
50
apps/shade/src/components/ui/table.stories.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell} from './table';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Table',
|
||||
component: Table,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
children: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Table>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">ABC-123</TableCell>
|
||||
<TableCell>Paid</TableCell>
|
||||
<TableCell>Card</TableCell>
|
||||
<TableCell className="text-right">$2,500.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>Total</TableCell>
|
||||
<TableCell className="text-right">$2,500.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
};
|
37
apps/shade/src/components/ui/tooltip.stories.tsx
Normal file
37
apps/shade/src/components/ui/tooltip.stories.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
import {Tooltip, TooltipTrigger, TooltipContent} from './tooltip';
|
||||
import {TooltipProvider} from '@radix-ui/react-tooltip';
|
||||
|
||||
const meta = {
|
||||
title: 'Components / Tooltip',
|
||||
component: Tooltip,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
Story => (
|
||||
<TooltipProvider>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
)
|
||||
],
|
||||
argTypes: {
|
||||
children: {
|
||||
table: {
|
||||
disable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof Tooltip>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tooltip>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Tooltip content</TooltipContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
};
|
|
@ -14,15 +14,17 @@ const TooltipContent = React.forwardRef<
|
|||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({className, sideOffset = 4, ...props}, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
<div className='shade'>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
|
|
@ -26,6 +26,26 @@ className={cn(buttonVariants({variant, size, className}))}
|
|||
|
||||
## TailwindCSS color naming
|
||||
|
||||
[TK]
|
||||
For gray colors up until now we've used `grey`. ShadCN follows TailwindCSS naming conventions and uses `gray` when you install a component. We also decided to go with this mainly to avoid having to manually override color names and risking manual errors in the design system.
|
||||
|
||||
## Filenames
|
||||
|
||||
Our generic naming conventions is:
|
||||
|
||||
- `PascalCase` for React component names
|
||||
- `kebab-case` for non-React-components like utilities, hooks and so on
|
||||
- `camelCase` for functions, objects, types etc.
|
||||
|
||||
When you install a ShadCN component via the CLI it'll create files with kebab-case. This is _not_ following our standards. Unfortunately changing _only_ filename casing in MacOS and Github is a **massive** PITA so for now we're accepting this inconsistency and let ShadCN create its files as is.
|
||||
|
||||
## File structure
|
||||
|
||||
- `/src/components/` — Root directory for components
|
||||
- `/src/components/ui/` — Directory for atomic UI components (foundational UI elements such as buttons, dropdowns etc.)
|
||||
- `/src/components/layout` — Complex, globally reusable components (such as a page or header container etc.)
|
||||
- `/src/hooks/` — Custom Reach hooks
|
||||
- `/src/providers/` — Context providers
|
||||
- `/src/ib/utils.ts` — Utilities
|
||||
- `/src/docs/` — Shade documentation
|
||||
|
||||
</div>
|
||||
|
|
|
@ -6,12 +6,18 @@ import { Meta } from '@storybook/blocks';
|
|||
|
||||
# Adding new components
|
||||
|
||||
ShadCN/UI is actually not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds a new React `tsx` file with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on.
|
||||
<p className="excerpt">ShadCN/UI is not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds new React component(s) with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on.</p>
|
||||
|
||||
As an example, let's see how to add a "Button" component.
|
||||
|
||||
---
|
||||
|
||||
**Step 1:** Go to [ShadCN/UI component list](https://ui.shadcn.com/docs/components) and find **Button**
|
||||
|
||||
> ⚠️ Sometimes the npx command fails with a massive error. This usually happens if ShadCN tries to reinstall an already existing third party library or component (e.g. `radix-ui/dialog` or `lucide-react`). In these cases, find out which package causes the issue (usually it's the one indicated under the manual installation of the given component), remove it from Shade's `package.json` and retry adding the ShadCN component via the CLI command.
|
||||
|
||||
---
|
||||
|
||||
**Step 2:** Install the component
|
||||
|
||||
In the `shade` folder follow the ShadCN/UI CLI install instructions:
|
||||
|
@ -35,6 +41,8 @@ If you take a look at the source of this component you'll see that the whole but
|
|||
- You can't "update" ShadCN/UI components, you can only reinstall. This means that if you re-run the install command it overwrites the existing implementation including all your changes, so be careful.
|
||||
- You can change the implementation as you wish. You can add, remove or CSS classes, create new variants and add UI logic if you want.
|
||||
|
||||
---
|
||||
|
||||
**Step 3:** Create stories for the new component
|
||||
|
||||
In order to make the new component appear in Storybook, you need to create at least one story for it. You can use the Boilerplate story as a starting point: copy it as `[component].stories.tsx` to the same folder as your component:
|
||||
|
@ -53,6 +61,8 @@ For a story, at a minimum you'll need to:
|
|||
- Update the imported component (for the button example: `import {Button} from './button'`), and update all references in the stories file
|
||||
- Change the meta data (most imporantly, `title: 'Components / Button'`, where "Components" will be the parent directory of Button in Storybook — this is a meta directory it doesn't have to exist in the file system)
|
||||
|
||||
---
|
||||
|
||||
**Step 4:** (Optional) Add or remove component variants
|
||||
|
||||
ShadCN/UI uses [Class Variance Authority](https://cva.style/docs) to manage variants for UI components. If you look at the Button component, you'll see an example implementation of this:
|
||||
|
@ -83,6 +93,8 @@ export interface ButtonProps
|
|||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Step 5:** Export the component
|
||||
|
||||
In order to be able to import the new component in apps, you'll need to export it in the `index.ts` file of Shade, like this:
|
||||
|
@ -93,6 +105,12 @@ export * from './components/ui/button';
|
|||
|
||||
That's it — you've just added a new component to Shade and made it ready to use in other React apps.
|
||||
|
||||
---
|
||||
|
||||
**Note:** ShadCN uses a few third party libraries. Since we export everything from Shade under the project `@tryghost/shade` there might be conflicts if a third party library uses similar component names as some other components in Shade. For example, ShadCN uses Recharts to display charts. Recharts has a `<Tooltip>` component and Shade also has one. To overcome this issue we alias all third party exports (e.g. `export * as Recharts from "recharts"`).
|
||||
|
||||
---
|
||||
|
||||
## Stories
|
||||
|
||||
With the automatic variant method, most of the _style related_ documentation is handled automatically in Shade. New stories should be created for non-stylistic use cases which are imporant to be able check. For the Button component an example could be related to the contents of the button: text-only, icon-only and icon-text buttons should be separated into different stories.
|
||||
|
@ -113,6 +131,10 @@ The `components.json` file contains the ShadCN/UI default configuration, so when
|
|||
|
||||
## Custom components
|
||||
|
||||
[TK]
|
||||
It is of course possible to create our own components. Examine how ShadCN components are structured and follow their patterns:
|
||||
|
||||
- Create a single file for each UI component
|
||||
- Create [composable components](https://blog.tomaszgil.me/choosing-the-right-path-composable-vs-configurable-components-in-react) (multiple React components), _not_ configurable ones (lots of props). Create all corresponding React components in the same file (take a look at the Dropdown Menu implementation for an example).
|
||||
- Export custom components the same way as you would with ShadCN ones.
|
||||
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@ In order to make the proper TailwindCSS classes appear in code completion, you n
|
|||
|
||||
Then, verify your VSCode settings:
|
||||
|
||||
1. Open settings.json (Cmd+Shift+P, then "Preferences: Open Settings (JSON)")
|
||||
1. Open `settings.json` (Cmd+Shift+P, then "Preferences: Open Settings (JSON)")
|
||||
2. Add these settings:
|
||||
|
||||
```
|
||||
|
@ -26,7 +26,7 @@ Then, verify your VSCode settings:
|
|||
}
|
||||
```
|
||||
|
||||
Also, ensure your project has a postcss.config.js file:
|
||||
Also, ensure your project has a `postcss.config.js` file:
|
||||
|
||||
```
|
||||
module.exports = {
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Meta } from '@storybook/blocks';
|
|||
|
||||
# Component usage
|
||||
|
||||
The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes.
|
||||
<p className="excerpt">The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes.</p>
|
||||
|
||||
## UI component API's
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Meta } from '@storybook/blocks';
|
||||
import techStackImage from './assets/tech-stack.png';
|
||||
|
||||
<Meta title="Welcome" />
|
||||
|
||||
|
@ -8,9 +9,10 @@ import { Meta } from '@storybook/blocks';
|
|||
|
||||
<p className="excerpt">**Shade** is Ghost's design system for product design. It contains all the necessary components and their usage that help you design a great product experience.</p>
|
||||
|
||||
## Stack
|
||||
|
||||
Shade uses the RadixUI based open source UI library, [ShadCN/UI](https://ui.shadcn.com/) as its foundation which does the heavy lifting and gives a great starting point for any UI component. Additionally Shade is built using [TailwindCSS](https://www.tailwindcss.com). Before diving deep into working with Shade, please get familiar with both of these libraries.
|
||||
|
||||
Shade uses [Storybook](https://storybook.js.org/) to document components and best practices.
|
||||
<img src={techStackImage} alt="Technology Stack" />
|
||||
|
||||
Shade uses [Storybook](https://storybook.js.org/) to test and document components and best practices. For every component used in production, there must be a Storybook entry with at least one story that showcases the component.
|
||||
|
||||
</div>
|
||||
|
|
BIN
apps/shade/src/docs/assets/tech-stack.png
Normal file
BIN
apps/shade/src/docs/assets/tech-stack.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 115 KiB |
|
@ -35,13 +35,11 @@ export {ReactComponent as GoogleLogo} from './assets/images/google-logo.svg';
|
|||
export {ReactComponent as TwitterLogo} from './assets/images/twitter-logo.svg';
|
||||
export {ReactComponent as XLogo} from './assets/images/x-logo.svg';
|
||||
|
||||
export {default as useGlobalDirtyState} from './hooks/useGlobalDirtyState';
|
||||
export {default as useGlobalDirtyState} from './hooks/use-global-dirty-state';
|
||||
|
||||
// Utils
|
||||
export * from '@/lib/utils';
|
||||
export {cn} from '@/lib/utils';
|
||||
export {debounce} from './utils/debounce';
|
||||
export {formatUrl} from './utils/formatUrl';
|
||||
export {cn, debounce, kebabToPascalCase, formatUrl} from '@/lib/utils';
|
||||
|
||||
export {default as ShadeApp} from './ShadeApp';
|
||||
export type {ShadeAppProps} from './ShadeApp';
|
||||
|
|
|
@ -1,6 +1,142 @@
|
|||
import {clsx, type ClassValue} from 'clsx';
|
||||
import isEmail from 'validator/es/lib/isEmail';
|
||||
import {twMerge} from 'tailwind-merge';
|
||||
|
||||
// Helper to merge Tailwind classes
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Helper to debounce a function
|
||||
export function debounce<T extends unknown[]>(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
return function (this: unknown, ...args: T): void {
|
||||
const later = () => {
|
||||
timeoutId = null;
|
||||
if (!immediate) {
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
|
||||
const callNow = immediate && !timeoutId;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(later, wait);
|
||||
|
||||
if (callNow) {
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to convert kebab-case to PascalCase with numbers
|
||||
export const kebabToPascalCase = (str: string): string => {
|
||||
const processed = str
|
||||
.replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
||||
return processed.charAt(0).toUpperCase() + processed.slice(1);
|
||||
};
|
||||
|
||||
// Helper to format a URL
|
||||
export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => {
|
||||
if (nullable && !value) {
|
||||
return {save: null, display: ''};
|
||||
}
|
||||
|
||||
let url = value.trim();
|
||||
|
||||
if (!url) {
|
||||
if (baseUrl) {
|
||||
return {save: '/', display: baseUrl};
|
||||
}
|
||||
return {save: '', display: ''};
|
||||
}
|
||||
|
||||
// if we have an email address, add the mailto:
|
||||
if (isEmail(url)) {
|
||||
return {save: `mailto:${url}`, display: `mailto:${url}`};
|
||||
}
|
||||
|
||||
const isAnchorLink = url.match(/^#/);
|
||||
if (isAnchorLink) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
const isProtocolRelative = url.match(/^(\/\/)/);
|
||||
if (isProtocolRelative) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
// Absolute URL with no base URL
|
||||
if (!url.startsWith('http')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc
|
||||
if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url, baseUrl);
|
||||
} catch (e) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return {save: parsedUrl.toString(), display: parsedUrl.toString()};
|
||||
}
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
|
||||
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
|
||||
|
||||
// if our path is only missing a trailing / mark it as relative
|
||||
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
|
||||
isRelativeToBasePath = true;
|
||||
}
|
||||
|
||||
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
|
||||
|
||||
// if relative to baseUrl, remove the base url before sending to action
|
||||
if (isOnSameHost && isRelativeToBasePath) {
|
||||
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
|
||||
url = url.replace(/^\/\//, '');
|
||||
url = url.replace(parsedBaseUrl.host, '');
|
||||
url = url.replace(parsedBaseUrl.pathname, '');
|
||||
|
||||
if (!url.match(/^\//)) {
|
||||
url = `/${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
|
||||
url = `${url}/`;
|
||||
}
|
||||
|
||||
// we update with the relative URL but then transform it back to absolute
|
||||
// for the input value. This avoids problems where the underlying relative
|
||||
// value hasn't changed even though the input value has
|
||||
return {save: url, display: displayFromBase(url, baseUrl)};
|
||||
};
|
||||
|
||||
// Helper to display a URL from a base URL
|
||||
const displayFromBase = (url: string, baseUrl: string) => {
|
||||
// Ensure base url has a trailing slash
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
// Remove leading slash from url
|
||||
if (url.startsWith('/')) {
|
||||
url = url.substring(1);
|
||||
}
|
||||
|
||||
return new URL(url, baseUrl).toString();
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import NiceModal from '@ebay/nice-modal-react';
|
|||
import React, {createContext, useContext, useState} from 'react';
|
||||
import {Toaster} from 'react-hot-toast';
|
||||
// import {FetchKoenigLexical} from '../global/form/HtmlEditor';
|
||||
import {GlobalDirtyStateProvider} from '../hooks/useGlobalDirtyState';
|
||||
import {GlobalDirtyStateProvider} from '../hooks/use-global-dirty-state';
|
||||
|
||||
interface ShadeContextType {
|
||||
isAnyTextFieldFocused: boolean;
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
export function debounce<T extends unknown[]>(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
return function (this: unknown, ...args: T): void {
|
||||
const later = () => {
|
||||
timeoutId = null;
|
||||
if (!immediate) {
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
|
||||
const callNow = immediate && !timeoutId;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(later, wait);
|
||||
|
||||
if (callNow) {
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
// Helper to convert kebab-case to PascalCase with numbers
|
||||
export const kebabToPascalCase = (str: string): string => {
|
||||
const processed = str
|
||||
.replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
||||
return processed.charAt(0).toUpperCase() + processed.slice(1);
|
||||
};
|
|
@ -1,100 +0,0 @@
|
|||
import isEmail from 'validator/es/lib/isEmail';
|
||||
|
||||
export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => {
|
||||
if (nullable && !value) {
|
||||
return {save: null, display: ''};
|
||||
}
|
||||
|
||||
let url = value.trim();
|
||||
|
||||
if (!url) {
|
||||
if (baseUrl) {
|
||||
return {save: '/', display: baseUrl};
|
||||
}
|
||||
return {save: '', display: ''};
|
||||
}
|
||||
|
||||
// if we have an email address, add the mailto:
|
||||
if (isEmail(url)) {
|
||||
return {save: `mailto:${url}`, display: `mailto:${url}`};
|
||||
}
|
||||
|
||||
const isAnchorLink = url.match(/^#/);
|
||||
if (isAnchorLink) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
const isProtocolRelative = url.match(/^(\/\/)/);
|
||||
if (isProtocolRelative) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
// Absolute URL with no base URL
|
||||
if (!url.startsWith('http')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc
|
||||
if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url, baseUrl);
|
||||
} catch (e) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return {save: parsedUrl.toString(), display: parsedUrl.toString()};
|
||||
}
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
|
||||
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
|
||||
|
||||
// if our path is only missing a trailing / mark it as relative
|
||||
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
|
||||
isRelativeToBasePath = true;
|
||||
}
|
||||
|
||||
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
|
||||
|
||||
// if relative to baseUrl, remove the base url before sending to action
|
||||
if (isOnSameHost && isRelativeToBasePath) {
|
||||
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
|
||||
url = url.replace(/^\/\//, '');
|
||||
url = url.replace(parsedBaseUrl.host, '');
|
||||
url = url.replace(parsedBaseUrl.pathname, '');
|
||||
|
||||
if (!url.match(/^\//)) {
|
||||
url = `/${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
|
||||
url = `${url}/`;
|
||||
}
|
||||
|
||||
// we update with the relative URL but then transform it back to absolute
|
||||
// for the input value. This avoids problems where the underlying relative
|
||||
// value hasn't changed even though the input value has
|
||||
return {save: url, display: displayFromBase(url, baseUrl)};
|
||||
};
|
||||
|
||||
const displayFromBase = (url: string, baseUrl: string) => {
|
||||
// Ensure base url has a trailing slash
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
// Remove leading slash from url
|
||||
if (url.startsWith('/')) {
|
||||
url = url.substring(1);
|
||||
}
|
||||
|
||||
return new URL(url, baseUrl).toString();
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import * as assert from 'assert/strict';
|
||||
import {formatUrl} from '../../../src/utils/formatUrl';
|
||||
import {formatUrl} from '@/lib/utils';
|
||||
|
||||
describe('formatUrl', function () {
|
||||
it('displays empty string if the input is empty and nullable is true', function () {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "mocha"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
@ -23,6 +23,6 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "test"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue