Skip to content

Commit

Permalink
Merge pull request #64 from complexdatacollective/feature/lucide-add-…
Browse files Browse the repository at this point in the history
…node

Feature: Use any lucide icon for the add node button
  • Loading branch information
jthrilly authored Oct 4, 2024
2 parents a132c40 + 39ead58 commit 08e41f3
Show file tree
Hide file tree
Showing 26 changed files with 363 additions and 244 deletions.
9 changes: 9 additions & 0 deletions .storybook/InterviewTheme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '~/styles/themes/interview.css';
import { createUseDisableImportedStyles } from './useDisableImportedStyles';

const useDisableImportedStyles = createUseDisableImportedStyles();

export default function InterviewTheme() {
useDisableImportedStyles();
return null;
}
14 changes: 10 additions & 4 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { Preview } from '@storybook/react';
import React, { useEffect, useState } from 'react';
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { type AbstractIntlMessages } from 'next-intl';
import {
SUPPORTED_LOCALE_OBJECTS,
type Locale,
} from '~/lib/localisation/config';
import { getLangDir } from 'rtl-detect';
import InjectThemeVariables from '~/lib/theme/InjectThemeVariables';
import { withThemeByDataAttribute } from '@storybook/addon-themes';
import '~/styles/global.css';
import '~/styles/themes/default.css';
import Providers from '~/app/_components/Providers';
import { Lexend, Roboto_Mono } from 'next/font/google';
import { cn } from '~/lib/utils';

const LazyInterviewTheme = lazy(() => import('./InterviewTheme'));

const lexend = Lexend({
weight: 'variable',
display: 'swap',
Expand Down Expand Up @@ -125,15 +127,19 @@ const preview: Preview = {
(context.parameters.forceTheme as string) ??
(context.globals.visualTheme as string);

console.log('theme', theme);

return (
<div
className={cn(
'min-h-full bg-background font-sans text-foreground',
`${lexend.variable} ${roboto_mono.variable}`,
)}
>
<InjectThemeVariables theme={theme} />
<Story />
<Suspense fallback="Loading...">
{theme === 'interview' && <LazyInterviewTheme />}
<Story />
</Suspense>
</div>
);
},
Expand Down
87 changes: 87 additions & 0 deletions .storybook/useDisableImportedStyles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useEffect } from 'react';

// global list of all the StyleSheets that are touched in useDisableImportedStyles
const switchableGlobalStyleSheets: StyleSheet[] = [];

// just to clarify what createUseDisableImportedStyles() returns
type useDisableImportedStyles = () => void;

/**
* Conditionally apply imported .css files
* WARNING: This is pretty finicky. You must set this up exactly or there may be unintended consequences
*
* ## Conditions:
*
* 1. `createUseDisableImportedStyles` must called in global scope in the same tsx file as the imported css being targeted and the component to be lazy loaded
* ```tsx
* import React from 'react'
* import { createUseDisableImportedStyles } from './useDisableImportedStyles'
* import './global-styles.css'
* const useDisableImportedStyles = createUseDisableImportedStyles()
* export const CssComponent: React.FC<{}> = () => {
* useDisableImportedStyles()
* return null
* }
* export default CssComponent
* ```
*
* 2. A component using this hook *should* be lazy loaded:
* ```tsx
* LazyCssComponent = React.lazy(() => import('./cssComponent'))
* ...
* <React.Suspense fallback={<></>}>
* {condition && <LazyCssComponent/>}
* </React.Suspense>
* ```
* - An exception to lazy loading might be using this in a single, normal, non-lazy component so styles are loaded on first render
* - NOTE: the `InitialCssComponent` never needs to actually render, it just needs to be imported
* - BUT: this will only work if there is **one single** .css file imported globally, otherwise, I don't know what would happen
* ```tsx
* import InitialCssComponent from './initialCssComponent'
* LazyCssComponent = React.lazy(() => import('./cssComponent'))
* ...
* {false && <InitialCssComponent/>}
* <React.Suspense fallback={<></>}>
* {condition && <LazyCssComponent/>}
* </React.Suspense>
* ```
*
* @param {boolean} immediatelyUnloadStyle
* if true: immediately unloads the StyleSheet when the component is unmounted
* if false: waits to unloads the StyleSheet until another instance of useDisableImportedStyles is called. This avoids a flash of unstyled content
*
*/
export const createUseDisableImportedStyles = (
immediatelyUnloadStyle = true,
): useDisableImportedStyles => {
let localStyleSheet: StyleSheet;
return () => {
useEffect(() => {
// if there are no stylesheets, you did something wrong...
if (document.styleSheets.length < 1) return;

// set the localStyleSheet if this is the first time this instance of this useEffect is called
if (localStyleSheet == null) {
localStyleSheet =
document.styleSheets[document.styleSheets.length - 1]!;
switchableGlobalStyleSheets.push(localStyleSheet);
}

// if we are switching StyleSheets, disable all switchableGlobalStyleSheets
if (!immediatelyUnloadStyle) {
switchableGlobalStyleSheets.forEach(
(styleSheet) => (styleSheet.disabled = true),
);
}

// enable our StyleSheet!
localStyleSheet.disabled = false;

// if we are NOT switching StyleSheets, disable this StyleSheet when the component is unmounted
if (immediatelyUnloadStyle)
return () => {
if (localStyleSheet != null) localStyleSheet.disabled = true;
};
});
};
};
9 changes: 1 addition & 8 deletions app/(interview)/interview/[interviewId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { getInterviewById } from '~/server/queries/interviews';
import InterviewShell from '~/components/interview/ui/InterviewShell';
import { redirect } from 'next/navigation';

export default async function Page({
params: { interviewId },
Expand All @@ -9,13 +8,7 @@ export default async function Page({
params: { interviewId: string };
searchParams: Record<string, string | string[] | undefined>;
}) {
let stage;
if (!searchParams.stage) {
stage = 0;
redirect(`/interview/${interviewId}?stage=0`);
} else {
stage = parseInt(searchParams.stage as string);
}
const stage = parseInt(searchParams.stage as string, 10) || 0;

const interviewData = await getInterviewById({ interviewId });
if (!interviewData) {
Expand Down
3 changes: 2 additions & 1 deletion app/(interview)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { getMessages, getLocale, getNow, getTimeZone } from 'next-intl/server';
import { Lexend, Roboto_Mono } from 'next/font/google';
import { type Metadata } from 'next';
import '~/styles/global.css';
import '~/public/themes/interview.css';
import '~/styles/themes/default.css';
import '~/styles/themes/interview.css';
import { Analytics } from '@vercel/analytics/react';
import { getLangDir } from 'rtl-detect';
import Providers from '~/app/_components/Providers';
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getMessages, getLocale, getNow, getTimeZone } from 'next-intl/server';
import { Lexend, Roboto_Mono } from 'next/font/google';
import { type Metadata } from 'next';
import '~/styles/global.css';
import '~/public/themes/default.css';
import '~/styles/themes/default.css';
import { Analytics } from '@vercel/analytics/react';
import { getLangDir } from 'rtl-detect';
import Providers from '~/app/_components/Providers';
Expand Down
2 changes: 0 additions & 2 deletions app/_components/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import { type AbstractIntlMessages } from 'next-intl';
import { type ReactNode } from 'react';
import RadixDirectionProvider from './RadixDirectionProvider';
Expand Down
38 changes: 38 additions & 0 deletions components/DynamicLucideIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type LucideProps, icons } from 'lucide-react';

type IconComponentName = keyof typeof icons;

type IconProps = {
name: string; // because this is coming from the CMS
} & LucideProps;

// 👮‍♀️ guard
function isValidIconComponent(
componentName: string,
): componentName is IconComponentName {
return componentName in icons;
}

// This is a workaround to issues with lucide-react/dynamicIconImports found at https://github.com/lucide-icons/lucide/issues/1576#issuecomment-2335019821
export default function DynamicLucideIcon({ name, ...props }: IconProps) {
// we need to convert kebab-case to PascalCase because we formerly relied on
// lucide-react/dynamicIconImports and the icon names are what are stored in the CMS.
const kebabToPascal = (str: string) =>
str
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');

const componentName = kebabToPascal(name);

// ensure what is in the CMS is a valid icon component
if (!isValidIconComponent(componentName)) {
return null;
}

// lucide-react/dynamicIconImports makes makes NextJS development server very slow
// https://github.com/lucide-icons/lucide/issues/1576
const Icon = icons[componentName];

return <Icon {...props} />;
}
9 changes: 4 additions & 5 deletions components/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function Node({
}: {
label: string;
size?: 'sm' | 'lg';
}) {
} & React.HTMLAttributes<HTMLButtonElement>) {
const labelWithEllipsis =
label.length < 22 ? label : `${label.substring(0, 18)}\u{AD}...`; // Add ellipsis for really long labels

Expand All @@ -19,16 +19,15 @@ export default function Node({
const nodeSizeClasses = size === 'sm' ? 'h-24 w-24' : 'h-36 w-36';

return (
<div
role="button"
<button
className={cn(
'text-node-1-foreground inline-flex items-center justify-center rounded-full bg-node-1',
'inline-flex items-center justify-center rounded-full bg-node-1 text-node-1-foreground',
'bg-[repeating-linear-gradient(145deg,transparent,transparent_50%,rgba(0,0,0,0.1)_50%,rgba(0,0,0,0.1)_100%)]',
nodeSizeClasses,
)}
aria-label={label}
>
<span className={labelClasses}>{labelWithEllipsis}</span>
</div>
</button>
);
}
52 changes: 52 additions & 0 deletions components/interview/ActionButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { type Meta, type StoryFn } from '@storybook/react';
import ActionButton from '~/components/interview/ActionButton';
import {
type NodeColor,
NodeColors,
type NodeIcon,
NodeIcons,
} from '~/schemas/protocol/codebook/entities';

export default {
title: 'Interview/ActionButton',
component: ActionButton,
parameters: {
forceTheme: 'interview',
layout: 'centered',
},
argTypes: {
iconName: {
control: {
type: 'select',
},
options: NodeIcons,
},
color: {
control: {
type: 'select',
},
options: NodeColors,
},
onClick: { action: 'clicked' }, // Action logger for click events
},
decorators: [
(Story) => (
<div className="bg-primary-background flex h-screen w-screen items-center justify-center">
<Story />
</div>
),
],
} as Meta;

const Template: StoryFn<{
iconName: NodeIcon;
color: NodeColor;
onClick: () => void;
}> = (args) => <ActionButton {...args} />;

export const Default = Template.bind({});
Default.args = {
iconName: 'user-round',
color: 'node-1',
};
58 changes: 50 additions & 8 deletions components/interview/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,56 @@
import AddAPersonIcon from './icons/add-a-person-svg-react';
import MenuNewSessionIcon from './icons/menu-new-session.svg.react';
import type { NodeColor, NodeIcon } from '~/schemas/protocol/codebook/entities';
import { PlusIcon } from 'lucide-react';
import DynamicLucideIcon from '../DynamicLucideIcon';
import { cn } from '~/lib/utils';

export default function ActionButton({ onClick }: { onClick: () => void }) {
type ActionButtonProps = React.ComponentProps<'button'> & {
iconName: NodeIcon;
color: NodeColor;
};

export default function ActionButton({
iconName,
className,
color,
...rest
}: ActionButtonProps) {
return (
<button onClick={onClick} className="relative flex h-40 w-40">
<div className="absolute inset-0 flex items-center justify-center rounded-full">
<AddAPersonIcon />
<button
{...rest}
aria-label="Add a person"
className={cn(
'group relative mr-4 mt-2 flex h-40 w-40 rounded-full',
className,
)}
>
<div
className={cn(
color === 'node-1' && 'bg-node-1 text-node-1-foreground',
color === 'node-2' && 'bg-node-2 text-node-2-foreground',
color === 'node-3' && 'bg-node-3 text-node-3-foreground',
color === 'node-4' && 'bg-node-4 text-node-4-foreground',
color === 'node-5' && 'bg-node-5 text-node-5-foreground',
color === 'node-6' && 'bg-node-6 text-node-6-foreground',
color === 'node-7' && 'bg-node-7 text-node-7-foreground',
color === 'node-8' && 'bg-node-8 text-node-8-foreground',
'scale-90 transition-transform duration-300 ease-in-out group-hover:scale-100',
'absolute inset-0 flex items-center justify-center rounded-full shadow-2xl',
'bg-[repeating-linear-gradient(145deg,transparent,transparent_50%,rgba(0,0,0,0.1)_50%,rgba(0,0,0,0.1)_100%)]',
)}
>
<DynamicLucideIcon
name={iconName}
className="h-24 w-24"
strokeWidth={1.5}
/>
</div>
<div className="absolute right-0 top-3 flex h-16 w-16 items-center justify-center rounded-full bg-muted p-5">
<MenuNewSessionIcon />
<div className="absolute -right-4 -top-2 flex h-16 w-16 items-center justify-center rounded-full bg-muted p-5 text-muted-foreground shadow-md">
<div>
<PlusIcon
className="h-10 w-10 transition-transform duration-300 ease-in-out group-hover:rotate-90"
strokeWidth={3}
/>
</div>
</div>
</button>
);
Expand Down
Loading

0 comments on commit 08e41f3

Please sign in to comment.