Skip to content

Commit

Permalink
Merge pull request #34 from thomasKn/thomas/fv-238-variant-selector-a…
Browse files Browse the repository at this point in the history
…nimation

Add variant selector animation
  • Loading branch information
thomasKn authored Feb 5, 2024
2 parents 674cf2c + 3250986 commit 0000bc1
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 62 deletions.
3 changes: 3 additions & 0 deletions app/components/cart/CartLineItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export function CartLineItem({
},
};

// Animated list implmentation inspired by the fantastic Build UI recipes
// (Check out the original at: https://buildui.com/recipes/animated-list)
// Credit to the Build UI team for the awesome List animation.
return (
<m.li
animate={
Expand Down
3 changes: 3 additions & 0 deletions app/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ function HeaderAnimation(props: {
[0, 0, 1],
);

// Header animation inspired by the fantastic Build UI recipes
// (Check out the original at: https://buildui.com/recipes/fixed-header)
// Credit to the Build UI team for the awesome Header animation.
return (
<m.header
className={cn(props.className)}
Expand Down
87 changes: 51 additions & 36 deletions app/components/product/AddToCartForm.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type {ProductVariantFragmentFragment} from 'storefrontapi.generated';

import {useNavigation} from '@remix-run/react';
import {CartForm, ShopPayButton} from '@shopify/hydrogen';
import {cx} from 'class-variance-authority';
import {useState} from 'react';

import {useEnvironmentVariables} from '~/hooks/useEnvironmentVariables';
import {useLocalePath} from '~/hooks/useLocalePath';
import {useSanityThemeContent} from '~/hooks/useSanityThemeContent';
import {useSelectedVariant} from '~/hooks/useSelectedVariant';
import {cn} from '~/lib/utils';

import {QuantitySelector} from '../QuantitySelector';
import CleanString from '../sanity/CleanString';
Expand All @@ -18,13 +19,15 @@ export function AddToCartForm(props: {
showShopPay?: boolean | null;
variants: ProductVariantFragmentFragment[];
}) {
const navigation = useNavigation();
const {showQuantitySelector, showShopPay, variants} = props;
const env = useEnvironmentVariables();
const {themeContent} = useSanityThemeContent();
const selectedVariant = useSelectedVariant({variants});
const isOutOfStock = !selectedVariant?.availableForSale;
const [quantity, setQuantity] = useState(1);
const cartPath = useLocalePath({path: '/cart'});
const navigationIsLoading = navigation.state !== 'idle';

return (
selectedVariant && (
Expand Down Expand Up @@ -58,42 +61,54 @@ export function AddToCartForm(props: {
}}
route={cartPath}
>
{(fetcher) => (
<div className="grid gap-3">
<Button
className={cx([isOutOfStock && 'opacity-50'])}
data-sanity-edit-target
disabled={isOutOfStock || fetcher.state !== 'idle'}
type="submit"
>
{isOutOfStock ? (
<CleanString value={themeContent?.product?.soldOut} />
) : (
<CleanString value={themeContent?.product?.addToCart} />
{(fetcher) => {
const isLoading = fetcher.state !== 'idle' || navigationIsLoading;

// Button is disabled if the variant is out of stock or if fetcher is not idle.
// Button is also disabled if navigation is loading (new variant is being fetched)
// to prevent adding the wrong variant to the cart.
return (
<div className="grid gap-3">
<Button
className={cn([
isOutOfStock && 'opacity-50',
// Opacity does not change when is loading to prevent flickering
'data-[loading="true"]:disabled:opacity-100',
])}
data-loading={isLoading}
data-sanity-edit-target
disabled={isOutOfStock || isLoading}
type="submit"
>
{isOutOfStock ? (
<CleanString value={themeContent?.product?.soldOut} />
) : (
<CleanString value={themeContent?.product?.addToCart} />
)}
</Button>
{showShopPay && (
<div className="h-10">
<ShopPayButton
className={cn([
'h-full',
(isLoading || isOutOfStock) &&
'pointer-events-none cursor-default',
isOutOfStock && 'opacity-50',
])}
storeDomain={`https://${env?.PUBLIC_STORE_DOMAIN!}`}
variantIdsAndQuantities={[
{
id: selectedVariant?.id!,
quantity: quantity,
},
]}
width="100%"
/>
</div>
)}
</Button>
{showShopPay && (
<div className="h-10">
<ShopPayButton
className={cx([
'h-full',
(fetcher.state !== 'idle' || isOutOfStock) &&
'pointer-events-none cursor-default',
isOutOfStock && 'opacity-50',
])}
storeDomain={`https://${env?.PUBLIC_STORE_DOMAIN!}`}
variantIdsAndQuantities={[
{
id: selectedVariant?.id!,
quantity: quantity,
},
]}
width="100%"
/>
</div>
)}
</div>
)}
</div>
);
}}
</CartForm>
</>
)
Expand Down
94 changes: 71 additions & 23 deletions app/components/product/VariantSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import type {
import type {PartialDeep} from 'type-fest';
import type {PartialObjectDeep} from 'type-fest/source/partial-deep';

import {Link} from '@remix-run/react';
import {useNavigate} from '@remix-run/react';
import {parseGid} from '@shopify/hydrogen';
import {useMemo} from 'react';
import {m} from 'framer-motion';
import {useCallback, useMemo, useState} from 'react';

import {useSelectedVariant} from '~/hooks/useSelectedVariant';
import {cn} from '~/lib/utils';

import {badgeVariants} from '../ui/Badge';

export type VariantOptionValue = {
isActive: boolean;
isAvailable: boolean;
Expand Down Expand Up @@ -102,26 +101,75 @@ export function VariantSelector(props: {
return options?.map((option) => (
<div key={option.name}>
<div>{option.name}</div>
<div className="mt-1 flex gap-2">
{option.values?.map(({isActive, isAvailable, search, value}) => (
<Link
className={cn([
badgeVariants({
variant: isActive ? 'secondary' : 'outline',
}),
!isAvailable && 'opacity-50',
'px-3 py-[.35rem] hover:bg-muted',
])}
key={option.name + value}
prefetch="viewport"
preventScrollReset
replace
to={search}
<Pills option={option} />
</div>
));
}

function Pills(props: {
option: {
name: string | undefined;
value: string | undefined;
values: VariantOptionValue[];
};
}) {
const navigate = useNavigate();
const {values} = props.option;
const [activePill, setActivePill] = useState(values[0]);

const handleOnClick = useCallback(
(value: string, search: string) => {
const newActivePill = values.find((option) => option.value === value);

if (newActivePill) {
setActivePill(newActivePill);
navigate(search, {
preventScrollReset: true,
replace: true,
});
}
},
[setActivePill, values, navigate],
);

// Animated tabs implementation inspired by the fantastic Build UI recipes
// (Check out the original at: https://buildui.com/recipes/animated-tabs)
// Credit to the Build UI team for the awesome Pills animation.
return (
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-3">
{props.option.values.map(({isAvailable, search, value}) => (
<m.button
className={cn([
'relative rounded-full text-sm font-medium transition',
'focus-visible:outline-none focus-visible:outline-2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'hover:text-accent-foreground',
activePill.value === value && 'text-accent-foreground',
!isAvailable && 'opacity-50',
])}
key={value}
layout
layoutRoot
onClick={() => handleOnClick(value, search)}
style={{
WebkitTapHighlightColor: 'transparent',
}}
>
{activePill.value === value && (
<m.span
className="absolute inset-0 z-10 bg-accent mix-blend-multiply"
layoutId={props.option.name}
style={{borderRadius: 9999}}
transition={{bounce: 0.2, duration: 0.6, type: 'spring'}}
/>
)}
<m.span
className="inline-flex h-8 select-none items-center justify-center whitespace-nowrap px-3 py-1.5"
whileTap={{scale: 0.9}}
>
{value}
</Link>
))}
</div>
</m.span>
</m.button>
))}
</div>
));
);
}
2 changes: 1 addition & 1 deletion app/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as React from 'react';
import {cn} from '~/lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
defaultVariants: {
size: 'default',
Expand Down
4 changes: 2 additions & 2 deletions app/lib/framerMotionFeatures.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {domAnimation} from 'framer-motion';
import {domMax} from 'framer-motion';

export default domAnimation;
export default domMax;

0 comments on commit 0000bc1

Please sign in to comment.