Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
118 changes: 118 additions & 0 deletions .changeset/true-shirts-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
"@bigcommerce/catalyst-core": patch
---

Improve accessibility for price displays by adding screen reader announcements for original prices, sale prices, and price ranges. Visual price elements are hidden from assistive technologies using `aria-hidden="true"` to prevent duplicate announcements, while visually hidden text provides context about pricing information.

## Migration steps

### Step 1: Update Cart Price Display

Update `core/vibes/soul/sections/cart/client.tsx` to add accessibility labels for sale prices:

```diff
{lineItem.salePrice && lineItem.salePrice !== lineItem.price ? (
<span className="font-medium @xl:ml-auto">
- <span className="line-through">{lineItem.price}</span> {lineItem.salePrice}
+ <span className="sr-only">{t('originalPrice', { price: lineItem.price })}</span>
+ <span aria-hidden="true" className="line-through">
+ {lineItem.price}
+ </span>{' '}
+ <span className="sr-only">{t('currentPrice', { price: lineItem.salePrice })}</span>
+ <span aria-hidden="true">{lineItem.salePrice}</span>
</span>
) : (
<span className="font-medium @xl:ml-auto">{lineItem.price}</span>
)}
```

### Step 2: Update PriceLabel Component

Update `core/vibes/soul/primitives/price-label/index.tsx` to add accessibility improvements for sale prices and price ranges:

```diff
import { clsx } from 'clsx';
+ import { useTranslations } from 'next-intl';

export function PriceLabel({ className, colorScheme = 'light', price }: Props) {
+ const t = useTranslations('Components.Price');

if (typeof price === 'string') {
return (
...
);
}

switch (price.type) {
case 'range':
return (
<span ...>
- {price.minValue}
- &nbsp;&ndash;&nbsp;
- {price.maxValue}
+ <span className="sr-only">
+ {t('range', { minValue: price.minValue, maxValue: price.maxValue })}
+ </span>
+ <span aria-hidden="true">
+ {price.minValue} - {price.maxValue}
+ </span>
</span>
);

case 'sale':
return (
<span className={clsx('block font-semibold', className)}>
+ <span className="sr-only">{t('originalPrice', { price: price.previousValue })}</span>
<span
+ aria-hidden="true"
className={clsx(
'font-normal line-through opacity-50',
...
)}
>
{price.previousValue}
</span>{' '}
+ <span className="sr-only">{t('currentPrice', { price: price.currentValue })}</span>
<span
+ aria-hidden="true"
className={clsx(
...
)}
>
{price.currentValue}
</span>
</span>
);
}
}
```

### Step 3: Add Translation Keys

Update `core/messages/en.json` to include new translation keys for price accessibility:

```diff
"Cart": {
"title": "Cart",
"heading": "Your cart",
"proceedToCheckout": "Proceed to checkout",
"increment": "Increase quantity",
"decrement": "Decrease quantity",
"removeItem": "Remove item",
"cartCombined": "We noticed you had items saved in a previous cart, so we've added them to your current cart for you.",
"cartRestored": "You started a cart on another device, and we've restored it here so you can pick up where you left off.",
"cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.",
+ "originalPrice": "Original price was {price}.",
+ "currentPrice": "Current price is {price}.",
```

```diff
},
+ "Price": {
+ "originalPrice": "Original price was {price}.",
+ "currentPrice": "Current price is {price}.",
+ "range": "Price from {minValue} to {maxValue}."
+ }
},
"GiftCertificates": {
```
7 changes: 7 additions & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@
"cartCombined": "We noticed you had items saved in a previous cart, so we've added them to your current cart for you.",
"cartRestored": "You started a cart on another device, and we've restored it here so you can pick up where you left off.",
"cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.",
"originalPrice": "Original price was {price}.",
"currentPrice": "Current price is {price}.",
"CheckoutSummary": {
"title": "Summary",
"subTotal": "Subtotal",
Expand Down Expand Up @@ -555,6 +557,11 @@
"description": "These cookies help us provide a better user experience and test new features."
}
}
},
"Price": {
"originalPrice": "Original price was {price}.",
"currentPrice": "Current price is {price}.",
"range": "Price from {minValue} to {maxValue}."
}
},
"GiftCertificates": {
Expand Down
16 changes: 13 additions & 3 deletions core/vibes/soul/primitives/price-label/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { clsx } from 'clsx';
import { useTranslations } from 'next-intl';

export interface PriceRange {
type: 'range';
Expand Down Expand Up @@ -35,6 +36,8 @@ interface Props {
* ```
*/
export function PriceLabel({ className, colorScheme = 'light', price }: Props) {
const t = useTranslations('Components.Price');

if (typeof price === 'string') {
return (
<span
Expand Down Expand Up @@ -65,16 +68,21 @@ export function PriceLabel({ className, colorScheme = 'light', price }: Props) {
className,
)}
>
{price.minValue}
&nbsp;&ndash;&nbsp;
{price.maxValue}
<span className="sr-only">
{t('range', { minValue: price.minValue, maxValue: price.maxValue })}
</span>
<span aria-hidden="true">
{price.minValue} - {price.maxValue}
</span>
</span>
);

case 'sale':
return (
<span className={clsx('block font-semibold', className)}>
<span className="sr-only">{t('originalPrice', { price: price.previousValue })}</span>
<span
aria-hidden="true"
className={clsx(
'font-normal line-through opacity-50',
{
Expand All @@ -85,7 +93,9 @@ export function PriceLabel({ className, colorScheme = 'light', price }: Props) {
>
{price.previousValue}
</span>{' '}
<span className="sr-only">{t('currentPrice', { price: price.currentValue })}</span>
<span
aria-hidden="true"
className={clsx(
{
light: 'text-[var(--price-light-sale-text,hsl(var(--foreground)))]',
Expand Down
10 changes: 9 additions & 1 deletion core/vibes/soul/sections/cart/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform
import { parseWithZod } from '@conform-to/zod';
import { clsx } from 'clsx';
import { ArrowRight, GiftIcon, Minus, Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
ComponentPropsWithoutRef,
startTransition,
Expand Down Expand Up @@ -512,6 +513,8 @@ function CounterForm({
action: (payload: FormData) => void;
onSubmit: (formData: FormData) => void;
}) {
const t = useTranslations('Cart');

const [form, fields] = useForm({
defaultValue: { id: lineItem.id },
shouldValidate: 'onBlur',
Expand Down Expand Up @@ -561,7 +564,12 @@ function CounterForm({
<div className="flex w-full flex-wrap items-center gap-x-5 gap-y-2">
{lineItem.salePrice && lineItem.salePrice !== lineItem.price ? (
<span className="font-medium @xl:ml-auto">
<span className="line-through">{lineItem.price}</span> {lineItem.salePrice}
<span className="sr-only">{t('originalPrice', { price: lineItem.price })}</span>
<span aria-hidden="true" className="line-through">
{lineItem.price}
</span>{' '}
<span className="sr-only">{t('currentPrice', { price: lineItem.salePrice })}</span>
<span aria-hidden="true">{lineItem.salePrice}</span>
</span>
) : (
<span className="font-medium @xl:ml-auto">{lineItem.price}</span>
Expand Down