Merge pull request #73 from sambecker/component-primitives

Enforce global baseline grid
This commit is contained in:
Sam Becker 2024-03-26 23:57:03 -05:00 committed by GitHub
commit 6fbb9fc1f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 447 additions and 139 deletions

View File

@ -0,0 +1,193 @@
'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import EntityLink from '@/components/primitives/EntityLink';
import LabeledIcon from '@/components/primitives/LabeledIcon';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { clsx } from 'clsx/lite';
import { useState } from 'react';
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { IoImageSharp } from 'react-icons/io5';
const DEBUG_LINES = new Array(22).fill(null);
export default function ComponentsPage() {
const [_debugGrid, setDebugGrid] = useState('true');
const [_debugComponents, setDebugComponents] = useState('false');
const debugGrid = _debugGrid === 'true';
const debugComponents = _debugComponents === 'true';
return (
<>
<h1 className="flex mb-6">
<div className="grow">
<span>Baseline Grid: </span>
<span className="text-dim">
<span className="md:hidden">13px / 18px</span>
<span className="hidden md:inline-block">14px / 20px</span>
</span>
</div>
<div className={clsx(
'flex gap-1',
'[&>*]:inline-flex [&>*]:gap-1 [&_input]:-translate-y-0.5',
)}>
<FieldSetWithStatus
id="grid"
label="Grid"
type="checkbox"
value={_debugGrid}
onChange={setDebugGrid}
/>
<FieldSetWithStatus
id="components"
label="Components"
type="checkbox"
value={_debugComponents}
onChange={setDebugComponents}
/>
</div>
</h1>
<div
className={clsx(
'flex gap-8',
debugGrid && 'bg-baseline-grid'
)}
>
<div className="[&>*]:flex">
<div>
<LabeledIcon
icon={<FaCamera size={12} />}
debug={debugComponents}
>
Camera<br />Line two
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<IoImageSharp />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<FaHandSparkles />}
label="Image"
debug={debugComponents}
/>
</div>
<div>
<EntityLink
icon={<FaHandSparkles />}
label="Image"
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon
icon={<IoMdCamera size={12} />}
debug={debugComponents}
>
Canon Mark III
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<PhotoFilmSimulationIcon simulation="astia" />}
label="Astia/Soft"
type="icon-last"
iconWide
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<EntityLink
icon={<PhotoFilmSimulationIcon simulation="astia" />}
label="Astia/Soft"
type="icon-last"
badged
debug={debugComponents}
/>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
<div>
<LabeledIcon icon={<FaUserAltSlash />} debug={debugComponents}>
Image
</LabeledIcon>
</div>
</div>
<div className={clsx(
debugComponents && '[&>*]:bg-gray-800',
'[&>*]:flex',
)}>
{DEBUG_LINES.map((_, i) =>
<div key={i}>
Line {(i + 1).toString().padStart(2, '0')}
</div>
)}
</div>
</div>
</>
);
}

View File

@ -21,7 +21,7 @@ export default function CameraHeader({
const camera = cameraFromPhoto(photos[0], cameraProp);
return (
<PhotoSetHeader
entity={<PhotoCamera {...{ camera }} hideAppleIcon />}
entity={<PhotoCamera {...{ camera }} contrast="high" hideAppleIcon />}
entityVerb="Photo"
entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)}

View File

@ -2,7 +2,9 @@ import { AiFillApple } from 'react-icons/ai';
import { pathForCamera } from '@/site/paths';
import { IoMdCamera } from 'react-icons/io';
import { Camera, formatCameraText } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoCamera({
camera,
@ -31,7 +33,7 @@ export default function PhotoCamera({
/>
: <IoMdCamera
size={12}
className="translate-x-[-1px] translate-y-[3.5px]"
className="translate-x-[-1px]"
/>}
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged}

View File

@ -27,8 +27,9 @@ export default function Badge({
);
case 'small':
return clsx(
'px-[0.3rem] py-1 rounded-[0.25rem]',
'text-[0.7rem] font-medium',
'h-max-baseline',
'px-[5px] py-[2.75px]',
'text-[0.7rem] font-medium rounded-[0.25rem]',
highContrast
? 'text-invert bg-invert'
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',

View File

@ -1,97 +0,0 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import Badge from './Badge';
import { clsx } from 'clsx/lite';
export interface EntityLinkExternalProps {
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
}
export default function EntityLink({
label,
labelSmall,
href,
icon,
title,
type = 'icon-first',
badged,
contrast = 'high',
hoverEntity,
}: {
label: ReactNode
labelSmall?: ReactNode
href: string
icon?: ReactNode
title?: string
hoverEntity?: ReactNode
} & EntityLinkExternalProps) {
const renderLabel = () => <>
<span className="xs:hidden">
{labelSmall ?? label}
</span>
<span className="hidden xs:inline-block">
{label}
</span>
</>;
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
default:
return 'text-medium';
}
};
return (
<span className="group inline-flex items-center gap-2 h-5">
<Link
href={href}
title={title}
className={clsx(
'inline-flex gap-[0.23rem]',
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
classForContrast(),
)}
>
{type !== 'icon-only' && <>
{badged
? <span className="h-6 inline-flex items-center">
<Badge
type="small"
highContrast={contrast === 'high'}
uppercase
interactive
>
{renderLabel()}
</Badge>
</span>
: <span className="uppercase">
{renderLabel()}
</span>}
</>}
{icon && type !== 'text-only' &&
<span className={clsx(
'flex-shrink-0',
'inline-flex min-w-[0.9rem]',
contrast === 'high'
? 'text-icon'
: classForContrast(),
type === 'icon-first' && 'order-first',
badged && 'translate-y-[4px]',
hoverEntity !== undefined && 'group-hover:hidden',
)}>
{icon}
</span>}
</Link>
{hoverEntity !== undefined &&
<span className="hidden group-hover:inline">
{hoverEntity}
</span>}
</span>
);
}

View File

@ -110,9 +110,8 @@ export default function OGTile({
/>}
</div>
<div className={clsx(
'md:text-lg',
'font-sans leading-tight',
'flex flex-col gap-1 p-3',
'font-sans leading-none',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 group-active:dark:bg-gray-900/50',
'group-hover:bg-gray-100 group-hover:dark:bg-gray-900/70',

View File

@ -24,7 +24,7 @@ export default function ShareModal({
<div className="space-y-3 md:space-y-4 w-full">
<div className={clsx(
'flex items-center gap-x-3',
'text-xl md:text-3xl leading-snug',
'text-2xl leading-snug',
)}>
<TbPhotoShare size={22} className="hidden xs:block" />
<div className="flex-grow">

View File

@ -0,0 +1,89 @@
import { ReactNode } from 'react';
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
export interface EntityLinkExternalProps {
type?: LabeledIconType
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
}
export default function EntityLink({
icon,
label,
labelSmall,
iconWide,
type,
badged,
contrast = 'medium',
href,
prefetch,
title,
hoverEntity,
debug,
}: {
icon: ReactNode
label: ReactNode
labelSmall?: ReactNode
iconWide?: boolean
href?: string
prefetch?: boolean
title?: string
hoverEntity?: ReactNode
debug?: boolean
} & EntityLinkExternalProps) {
const classForContrast = () => {
switch (contrast) {
case 'low':
return 'text-dim';
case 'high':
return 'text-main';
default:
return 'text-medium';
}
};
const renderLabel = () => <>
<span className="xs:hidden">
{labelSmall ?? label}
</span>
<span className="hidden xs:inline-block">
{label}
</span>
</>;
return (
<span className="group inline-flex gap-2">
<LabeledIcon {...{
icon,
iconWide,
href,
prefetch,
title,
type,
className: clsx(
classForContrast(),
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
),
debug,
}}>
{badged
? <Badge
type="small"
highContrast={contrast === 'high'}
className='translate-y-[-0.5px]'
uppercase
interactive
>
{renderLabel()}
</Badge>
: renderLabel()}
</LabeledIcon>
{hoverEntity !== undefined &&
<span className="hidden group-hover:inline">
{hoverEntity}
</span>}
</span>
);
}

View File

@ -0,0 +1,35 @@
import { ReactNode } from 'react';
import { clsx } from 'clsx/lite';
import Spinner from '../Spinner';
export default function Icon({
children,
className,
iconClassName,
wide,
loading,
debug,
}: {
children: ReactNode
className?: string
iconClassName?: string
wide?: boolean
loading?: boolean
debug?: boolean,
}) {
return (
<span className={clsx(
'h-[18px] md:h-[20px]',
wide ? 'w-[28px]' : 'w-[14px]',
'inline-flex items-center justify-center',
debug && 'bg-gray-700',
className,
)}>
{loading
? <Spinner />
: <span className={iconClassName}>
{children}
</span>}
</span>
);
}

View File

@ -0,0 +1,60 @@
import { ComponentProps, ReactNode } from 'react';
import Icon from './Icon';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
export type LabeledIconType =
'icon-first' |
'icon-last' |
'icon-only' |
'text-only';
export default function LabeledIcon({
icon,
type = 'icon-first',
className: classNameProp,
children,
iconWide,
href,
prefetch,
debug,
}: {
icon?: ReactNode,
type?: LabeledIconType,
className?: string,
children: ReactNode,
iconWide?:boolean,
debug?: boolean,
} & Partial<ComponentProps<typeof Link>>) {
const className = clsx(
'inline-flex gap-x-1 md:gap-x-1.5',
classNameProp,
debug && 'border border-green-500 m-[-1px]',
);
const renderContent = () => <>
{icon && type !== 'text-only' &&
<Icon {...{
className: clsx(type === 'icon-last' && 'order-1'),
wide: iconWide,
debug,
}}>
{icon}
</Icon>}
{children && type !== 'icon-only' &&
<span className={clsx(
'uppercase',
debug && 'bg-gray-700'
)}>
{children}
</span>}
</>;
return href
? <Link {...{ href, prefetch, className }}>
{renderContent()}
</Link>
: <div {...{ className }}>
{renderContent()}
</div>;
}

View File

@ -65,10 +65,9 @@ export default function PhotoLarge({
contentSide={
<div className={clsx(
'relative',
'leading-snug',
'sticky top-4 self-start -translate-y-1',
'grid grid-cols-2 md:grid-cols-1',
'gap-x-0.5 sm:gap-x-1 gap-y-4',
'gap-x-0.5 sm:gap-x-1 gap-y-baseline',
'pb-6',
)}>
{/* Meta */}
@ -88,7 +87,7 @@ export default function PhotoLarge({
</div>
</Suspense>
</div>
<div className="space-y-4">
<div className="space-y-baseline">
{photo.caption &&
<div className="uppercase">
{photo.caption}
@ -106,7 +105,7 @@ export default function PhotoLarge({
</div>
</div>
{/* EXIF Data */}
<div className="space-y-4">
<div className="space-y-baseline">
{showExifContent &&
<>
<ul className="text-medium">
@ -134,8 +133,8 @@ export default function PhotoLarge({
/>}
</>}
<div className={clsx(
'flex gap-2',
'md:flex-col md:gap-4 md:justify-normal',
'flex gap-x-1.5 gap-y-baseline',
'md:flex-col md:justify-normal',
)}>
<div className={clsx(
'text-medium uppercase pr-1',

View File

@ -2,7 +2,9 @@ import { labelForFilmSimulation } from '@/vendors/fujifilm';
import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon';
import { pathForFilmSimulation } from '@/site/paths';
import { FilmSimulation } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoFilmSimulation({
simulation,
@ -21,15 +23,13 @@ export default function PhotoFilmSimulation({
label={medium}
labelSmall={small}
href={pathForFilmSimulation(simulation)}
icon={<PhotoFilmSimulationIcon
simulation={simulation}
className="translate-y-[-1px]"
/>}
icon={<PhotoFilmSimulationIcon simulation={simulation} />}
title={`Film Simulation: ${large}`}
type={type}
badged={badged}
contrast={contrast}
hoverEntity={countOnHover}
iconWide
/>
);
}

View File

@ -38,7 +38,7 @@ export default function FooterClient({
'flex items-center',
'text-dim min-h-[4rem]',
)}>
<div className="flex gap-x-4 gap-y-1 flex-grow flex-wrap h-4">
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&

View File

@ -57,7 +57,6 @@ export default function NavClient({
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
'leading-none',
)}>
<div className="flex-grow">
<ViewSwitcher

View File

@ -138,7 +138,7 @@ export default function SiteChecklistClient({
</div>;
return (
<div className="text-sm max-w-xl space-y-6 w-full">
<div className="max-w-xl space-y-6 w-full">
<Checklist
title="Storage"
icon={<BiData size={16} />}

View File

@ -116,7 +116,7 @@
hover:text-gray-600
hover:dark:text-gray-400
}
/* Common Utilities: Text */
/* Utilities: Text */
.text-main {
@apply
text-gray-900 dark:text-gray-100
@ -145,7 +145,7 @@
@apply
text-red-500 dark:text-red-400
}
/* Common Utilities: Background */
/* Utilities: Background */
.bg-main {
@apply
bg-white dark:bg-black
@ -159,4 +159,28 @@
@apply
bg-gray-900 dark:bg-gray-100
}
/* Utilities: Baseline Grid */
.space-y-baseline {
@apply
space-y-[1.1875rem] md:space-y-[1.25rem]
}
.gap-y-baseline {
@apply
gap-y-[1.1875rem] md:gap-y-[1.25rem]
}
.gap-baseline {
@apply
gap-[1.1875rem] md:gap-[1.25rem]
}
.max-h-baseline {
@apply
max-h-[1.1875rem] md:max-h-[1.25rem]
}
.bg-baseline-grid {
@apply
bg-[repeating-linear-gradient(to_bottom,#eee,#eee_1px,transparent_1px,transparent_1.1875rem)]
md:bg-[repeating-linear-gradient(to_bottom,#eee,#eee_1px,transparent_1px,transparent_1.25rem)]
dark:bg-[repeating-linear-gradient(to_bottom,#222,#222_1px,transparent_1px,transparent_1.1875rem)]
dark:md:bg-[repeating-linear-gradient(to_bottom,#222,#222_1px,transparent_1px,transparent_1.25rem)]
}
}

View File

@ -1,8 +1,10 @@
import { FaStar } from 'react-icons/fa';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import { TAG_FAVS } from '.';
import { pathForTag } from '@/site/paths';
import { clsx } from 'clsx/lite';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function FavsTag({
type,
@ -30,7 +32,7 @@ export default function FavsTag({
size={12}
className={clsx(
'text-amber-500',
'translate-x-[-1px] translate-y-[3.5px]',
'translate-x-[-1px] translate-y-[-0.5px]',
)}
/>}
type={type}

View File

@ -1,7 +1,9 @@
import { pathForTag } from '@/site/paths';
import { FaTag } from 'react-icons/fa';
import { formatTag } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
export default function PhotoTag({
tag,
@ -19,7 +21,7 @@ export default function PhotoTag({
href={pathForTag(tag)}
icon={<FaTag
size={11}
className="translate-y-[5px]"
className="translate-y-[1px]"
/>}
type={type}
badged={badged}

View File

@ -1,7 +1,7 @@
import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.';
import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/EntityLink';
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
export default function PhotoTags({
tags,
@ -10,13 +10,13 @@ export default function PhotoTags({
tags: string[]
} & EntityLinkExternalProps) {
return (
<div className="-space-y-0.5">
<div className="flex flex-col">
{tags.map(tag =>
<div key={tag}>
<>
{isTagFavs(tag)
? <FavsTag {...{ contrast }} />
: <PhotoTag {...{ tag, contrast }} />}
</div>)}
? <FavsTag {...{ key:tag, contrast }} />
: <PhotoTag {...{ key:tag, tag, contrast }} />}
</>)}
</div>
);
}

View File

@ -22,7 +22,7 @@ export default function TagHeader({
<PhotoSetHeader
entity={isTagFavs(tag)
? <FavsTag />
: <PhotoTag tag={tag} />}
: <PhotoTag tag={tag} contrast="high" />}
entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
photos={photos}

View File

@ -12,13 +12,13 @@ module.exports = {
...defaultTheme.screens,
},
fontSize: {
'xs': '0.75rem',
'sm': ['0.825rem', '1.15rem'],
'base': ['0.875rem', '1.275rem'],
'lg': ['0.925rem', '1.05rem'],
'xl': '1rem',
'2xl': '1.1rem',
'3xl': ['1.3rem', '1.7rem'],
'xs': ['0.75rem', '1rem'], // 12px on 16px
'sm': ['0.84375rem', '1.1875rem'], // 13.5px on 19px [Default: mobile]
'base': ['0.875rem', '1.25rem'], // 14px on 20px [Default: desktop]
'lg': ['1rem', '1.25rem'], // 16px on 20px
'xl': ['1.125rem', '1.25rem'], // 18px on 20px
'2xl': ['1.25rem', '1.25rem'], // 20px on 20px
'3xl': ['1.5rem', '1.5rem'], // 24px on 24px
},
extend: {
fontFamily: {