Skip to content

Commit b590bde

Browse files
authored
Merge pull request #95 from makeswift/hunter/des-1391-counter
feat: added Counter component?
2 parents ffd8eae + dc88402 commit b590bde

File tree

11 files changed

+333
-0
lines changed

11 files changed

+333
-0
lines changed

.changeset/tidy-facts-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'commerce-toolkit': minor
3+
---
4+
5+
Added Counter component

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@
107107
"default": "./dist/chip.cjs"
108108
}
109109
},
110+
"./counter": {
111+
"import": {
112+
"types": "./dist/components/counter/primitives.d.ts",
113+
"default": "./dist/counter.js"
114+
},
115+
"require": {
116+
"types": "./dist/components/counter/primitives.d.ts",
117+
"default": "./dist/counter.cjs"
118+
}
119+
},
110120
"./compare-card": {
111121
"import": {
112122
"types": "./dist/components/compare-card/primitives.d.ts",

src/components/counter/counter.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client';
2+
3+
import { Minus, Plus } from 'lucide-react';
4+
5+
import * as CounterPrimitive from '@/components/counter';
6+
7+
export interface CounterProps {
8+
start?: number;
9+
max?: number;
10+
decrementAriaLabel?: string;
11+
incrementAriaLabel?: string;
12+
}
13+
14+
/**
15+
* This component supports various CSS variables for theming. Here's a comprehensive list, along
16+
* with their default values:
17+
*
18+
* ```css
19+
* :root {
20+
* --counter-focus: hsl(var(--primary));
21+
* --counter-font-family: var(--font-family-body);
22+
* --counter-background: hsl(var(--background));
23+
* --counter-background-hover: color-mix(in oklab, hsl(var(--contrast-100)) 50%, transparent);
24+
* --counter-border: hsl(var(--contrast-100));
25+
* --counter-text: hsl(var(--foreground));
26+
* --counter-icon-hover: hsl(var(--foreground));
27+
* --counter-icon: hsl(var(--contrast-300));
28+
* }
29+
* ```
30+
*/
31+
export function Counter({
32+
start = 0,
33+
max = 10,
34+
decrementAriaLabel = 'Decrease count',
35+
incrementAriaLabel = 'Increase count',
36+
}: CounterProps) {
37+
return (
38+
<CounterPrimitive.Root max={max} start={start}>
39+
<CounterPrimitive.Decrease aria-label={decrementAriaLabel}>
40+
<Minus
41+
absoluteStrokeWidth
42+
className="text-[var(--counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300 group-data-[state=enabled]:group-hover:text-[var(--counter-icon-hover,hsl(var(--foreground)))]"
43+
size={18}
44+
strokeWidth={1.5}
45+
/>
46+
</CounterPrimitive.Decrease>
47+
<CounterPrimitive.Input readOnly />
48+
<CounterPrimitive.Increase aria-label={incrementAriaLabel}>
49+
<Plus
50+
absoluteStrokeWidth
51+
className="text-[var(--counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300 group-data-[state=enabled]:group-hover:text-[var(--counter-icon-hover,hsl(var(--foreground)))]"
52+
size={18}
53+
strokeWidth={1.5}
54+
/>
55+
</CounterPrimitive.Increase>
56+
</CounterPrimitive.Root>
57+
);
58+
}

src/components/counter/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Counter, type CounterProps } from '@/components/counter/counter';
2+
export * from '@/components/counter/primitives';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export {
2+
CounterRoot as Root,
3+
type CounterRootProps as RootProps,
4+
useCounter,
5+
} from '@/components/counter/primitives/counter-root';
6+
export {
7+
CounterDecrease as Decrease,
8+
type CounterDecreaseProps as DecreaseProps,
9+
} from '@/components/counter/primitives/counter-decrease';
10+
export {
11+
CounterIncrease as Increase,
12+
type CounterIncreaseProps as IncreaseProps,
13+
} from '@/components/counter/primitives/counter-increase';
14+
export {
15+
CounterInput as Input,
16+
type CounterInputProps as InputProps,
17+
} from '@/components/counter/primitives/counter-input';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import type { ComponentProps } from 'react';
4+
5+
import { useCounter } from '@/components/counter';
6+
import { cn } from '@/lib';
7+
8+
export type CounterDecreaseProps = ComponentProps<'button'>;
9+
10+
export function CounterDecrease({ children, className, ...props }: CounterDecreaseProps) {
11+
const { count, decrement } = useCounter();
12+
13+
return (
14+
<button
15+
className={cn(
16+
'group z-[1] rounded-l-lg p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--counter-focus,hsl(var(--primary)))] enabled:hover:bg-[var(--counter-background-hover,color-mix(in_oklab,hsl(var(--contrast-100))_50%,transparent))] disabled:cursor-not-allowed disabled:opacity-50',
17+
className,
18+
)}
19+
data-slot="counter-decrease"
20+
data-state={count > 0 ? 'enabled' : 'disabled'}
21+
disabled={count === 0}
22+
onClick={decrement}
23+
type="button"
24+
{...props}
25+
>
26+
{children}
27+
</button>
28+
);
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import type { ComponentProps } from 'react';
4+
5+
import { useCounter } from '@/components/counter';
6+
import { cn } from '@/lib';
7+
8+
export type CounterIncreaseProps = ComponentProps<'button'>;
9+
10+
export function CounterIncrease({ children, className, ...props }: CounterIncreaseProps) {
11+
const { count, max, increment } = useCounter();
12+
13+
return (
14+
<button
15+
className={cn(
16+
'group z-[1] rounded-r-lg p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--counter-focus,hsl(var(--primary)))] enabled:hover:bg-[var(--counter-background-hover,color-mix(in_oklab,hsl(var(--contrast-100))_50%,transparent))] disabled:cursor-not-allowed disabled:opacity-50',
17+
className,
18+
)}
19+
data-slot="counter-increase"
20+
data-state={count < max ? 'enabled' : 'disabled'}
21+
disabled={count === max}
22+
onClick={increment}
23+
type="button"
24+
{...props}
25+
>
26+
{children}
27+
</button>
28+
);
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
3+
import type { ComponentProps } from 'react';
4+
5+
import { useCounter } from '@/components/counter';
6+
import { cn } from '@/lib';
7+
8+
export type CounterInputProps = Omit<ComponentProps<'input'>, 'type'>;
9+
10+
export function CounterInput({ className, ...props }: CounterInputProps) {
11+
const { count, max } = useCounter();
12+
13+
return (
14+
<input
15+
className={cn(
16+
'w-8 bg-transparent text-center [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
17+
className,
18+
)}
19+
data-slot="counter-input"
20+
max={max}
21+
type="number"
22+
value={count}
23+
{...props}
24+
/>
25+
);
26+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
3+
import { createContext, use, useCallback, useMemo, useState } from 'react';
4+
import type { ComponentProps } from 'react';
5+
6+
import { cn } from '@/lib';
7+
8+
interface CounterContext {
9+
count: number;
10+
max: number;
11+
decrement: () => void;
12+
increment: () => void;
13+
}
14+
15+
export const CounterContext = createContext<CounterContext | undefined>(undefined);
16+
17+
export type CounterRootProps = ComponentProps<'div'> & {
18+
start?: number;
19+
max?: number;
20+
};
21+
22+
export function CounterRoot({
23+
children,
24+
className,
25+
start = 0,
26+
max = 10,
27+
...props
28+
}: CounterRootProps) {
29+
const [count, setCount] = useState(start);
30+
31+
const decrement = useCallback(() => {
32+
setCount((prev) => prev - 1);
33+
}, [max]);
34+
35+
const increment = useCallback(() => {
36+
setCount((prev) => prev + 1);
37+
}, [max]);
38+
39+
const contextValues = useMemo(
40+
() => ({
41+
count,
42+
max,
43+
decrement,
44+
increment,
45+
}),
46+
[count, max, decrement, increment],
47+
);
48+
49+
return (
50+
<CounterContext.Provider value={contextValues}>
51+
<div
52+
className={cn(
53+
'flex items-center justify-between rounded-lg border border-[var(--counter-border,hsl(var(--contrast-100)))] bg-[var(--counter-background,hsl(var(--background)))] font-[var(--counter-font-family,var(--font-family-body))] text-[var(--counter-text,hsl(var(--foreground)))]',
54+
className,
55+
)}
56+
data-slot="counter-root"
57+
{...props}
58+
>
59+
{children}
60+
</div>
61+
</CounterContext.Provider>
62+
);
63+
}
64+
65+
export function useCounter() {
66+
const context = use(CounterContext);
67+
68+
if (context === undefined) {
69+
throw new Error('useCounter must be used within a CounterRoot');
70+
}
71+
72+
return context;
73+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { Counter } from '@/components/counter';
4+
5+
const meta = {
6+
title: 'Components/Counter',
7+
component: Counter,
8+
parameters: {
9+
layout: 'centered',
10+
},
11+
tags: ['autodocs'],
12+
argTypes: {
13+
start: {
14+
control: 'number',
15+
description: 'Initial count value',
16+
table: {
17+
defaultValue: { summary: '0' },
18+
},
19+
},
20+
max: {
21+
control: 'number',
22+
description: 'Maximum count value (increment button disabled at this value)',
23+
table: {
24+
defaultValue: { summary: '10' },
25+
},
26+
},
27+
decrementAriaLabel: {
28+
control: 'text',
29+
description: 'Accessible label for the decrement button',
30+
table: {
31+
defaultValue: { summary: 'Decrease count' },
32+
},
33+
},
34+
incrementAriaLabel: {
35+
control: 'text',
36+
description: 'Accessible label for the increment button',
37+
table: {
38+
defaultValue: { summary: 'Increase count' },
39+
},
40+
},
41+
},
42+
args: {
43+
start: 0,
44+
max: 10,
45+
decrementAriaLabel: 'Decrease count',
46+
incrementAriaLabel: 'Increase count',
47+
},
48+
} satisfies Meta<typeof Counter>;
49+
50+
export default meta;
51+
52+
type Story = StoryObj<typeof meta>;
53+
54+
export const Default: Story = {
55+
args: {},
56+
};
57+
58+
export const WithInitialValue: Story = {
59+
args: {
60+
start: 5,
61+
},
62+
};
63+
64+
export const SmallRange: Story = {
65+
args: {
66+
max: 3,
67+
},
68+
};
69+
70+
export const AtMaximum: Story = {
71+
args: {
72+
start: 10,
73+
max: 10,
74+
},
75+
};
76+
77+
export const LargeRange: Story = {
78+
args: {
79+
max: 100,
80+
},
81+
};

0 commit comments

Comments
 (0)