Skip to content

Commit 1f104b3

Browse files
committed
Implement Experience Control component
1 parent d0bdafb commit 1f104b3

File tree

10 files changed

+328
-81
lines changed

10 files changed

+328
-81
lines changed

client/a8c-for-agencies/components/a4a-feedback/index.tsx

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { FormLabel } from '@automattic/components';
1+
import { FormLabel, ExperienceControl } from '@automattic/components';
22
import { Button, CheckboxControl } from '@wordpress/components';
33
import { useTranslate } from 'i18n-calypso';
44
import { ChangeEvent, useState } from 'react';
55
import { LayoutWithGuidedTour as Layout } from 'calypso/a8c-for-agencies/components/layout/layout-with-guided-tour';
6-
import IconBad from 'calypso/assets/images/a8c-for-agencies/feedback/bad.svg';
7-
import IconGood from 'calypso/assets/images/a8c-for-agencies/feedback/good.svg';
8-
import IconNeutral from 'calypso/assets/images/a8c-for-agencies/feedback/neutral.svg';
96
import FormFieldset from 'calypso/components/forms/form-fieldset';
107
import FormTextarea from 'calypso/components/forms/form-textarea';
118
import LayoutBody from 'calypso/layout/hosting-dashboard/body';
@@ -46,31 +43,11 @@ export function A4AFeedback( { type }: { type: FeedbackType } ) {
4643
<div className="a4a-feedback__question-details">
4744
{ translate( 'Share your feedback' ) }
4845
</div>
49-
<div className="a4a-feedback__experience-selector">
50-
<div className="a4a-feedback__experience-selector-label">
51-
{ translate( 'What was your experience like?' ) }
52-
</div>
53-
<div className="a4a-feedback__experience-selector-buttons">
54-
<Button
55-
variant={ experience === 'good' ? 'primary' : 'secondary' }
56-
onClick={ () => setExperience( 'good' ) }
57-
>
58-
<img src={ IconGood } alt="Good" />
59-
</Button>
60-
<Button
61-
variant={ experience === 'neutral' ? 'primary' : 'secondary' }
62-
onClick={ () => setExperience( 'neutral' ) }
63-
>
64-
<img src={ IconNeutral } alt="Neutral" />
65-
</Button>
66-
<Button
67-
variant={ experience === 'bad' ? 'primary' : 'secondary' }
68-
onClick={ () => setExperience( 'bad' ) }
69-
>
70-
<img src={ IconBad } alt="Bad" />
71-
</Button>
72-
</div>
73-
</div>
46+
<ExperienceControl
47+
label={ translate( 'What was your experience like?' ) }
48+
onChange={ ( experience ) => setExperience( experience ) }
49+
selectedExperience={ experience }
50+
/>
7451
{ suggestion && (
7552
<FormFieldset>
7653
<FormLabel className="a4a-feedback__comments-label" htmlFor="suggestion">

client/a8c-for-agencies/components/a4a-feedback/style.scss

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -102,40 +102,6 @@ $highlight-color: #3858E9;
102102
}
103103
}
104104

105-
.theme-a8c-for-agencies .a4a-feedback__experience-selector-buttons {
106-
display: flex;
107-
gap: 10px;
108-
109-
.components-button {
110-
box-shadow: none;
111-
height: 40px;
112-
padding: 8px;
113-
114-
&.is-primary {
115-
border: 2px solid $highlight-color;
116-
background-color: unset;
117-
118-
&:focus:not(:disabled), &:hover:not(:disabled), &:focus-visible:not(:disabled) {
119-
border: 2px solid $highlight-color;
120-
}
121-
}
122-
&.is-secondary {
123-
border: 2px solid transparent;
124-
125-
&:focus:not(:disabled), &:hover:not(:disabled), &:focus-visible:not(:disabled) {
126-
border: 2px solid transparent;
127-
}
128-
}
129-
130-
&.is-primary, &.is-secondary {
131-
&:focus:not(:disabled), &:hover:not(:disabled), &:focus-visible:not(:disabled) {
132-
box-shadow: none;
133-
background-color: unset;
134-
}
135-
}
136-
}
137-
}
138-
139105
.a4a-feedback__suggestions {
140106
display: flex;
141107
flex-direction: column;

client/assets/images/a8c-for-agencies/feedback/bad.svg

Lines changed: 0 additions & 6 deletions
This file was deleted.

client/assets/images/a8c-for-agencies/feedback/good.svg

Lines changed: 0 additions & 6 deletions
This file was deleted.

client/assets/images/a8c-for-agencies/feedback/neutral.svg

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Experience Control
2+
3+
A flexible component for capturing user experience feedback through a set of options. By default, it provides a three-state experience selector (Good, Neutral, Bad) with icons, but it can also be customized for different use cases.
4+
5+
## Usage
6+
7+
### Default Usage
8+
9+
```jsx
10+
import { ExperienceControl } from '@automattic/components';
11+
12+
function MyComponent() {
13+
const [ experience, setExperience ] = useState( 'neutral' );
14+
15+
return (
16+
<ExperienceControl
17+
label="How was your experience?"
18+
selectedExperience={ experience }
19+
onChange={ setExperience }
20+
/>
21+
);
22+
}
23+
```
24+
25+
### Custom Implementation
26+
27+
The component exports base components that can be used to create custom experience controls:
28+
29+
```jsx
30+
import { ExperienceControl } from '@automattic/components';
31+
32+
function CustomExperienceControl() {
33+
const [ rating, setRating ] = useState( 'medium' );
34+
35+
const options = [
36+
{ value: 'high', label: 'High Priority' },
37+
{ value: 'medium', label: 'Medium Priority' },
38+
{ value: 'low', label: 'Low Priority' },
39+
];
40+
41+
return (
42+
<ExperienceControl.Base label="Task Priority">
43+
{ options.map( ( option ) => (
44+
<ExperienceControl.Option
45+
key={ option.value }
46+
className={ `is-${ option.value }` }
47+
isSelected={ rating === option.value }
48+
onClick={ () => setRating( option.value ) }
49+
helpText={ option.label }
50+
>
51+
{ option.label }
52+
</ExperienceControl.Option>
53+
) ) }
54+
</ExperienceControl.Base>
55+
);
56+
}
57+
```
58+
59+
## Props
60+
61+
### ExperienceControl
62+
63+
| Prop | Type | Required | Description |
64+
| -------------------- | ---------------------------- | -------- | ------------------------------------------ |
65+
| `label` | string | Yes | The label displayed above the control |
66+
| `selectedExperience` | string | Yes | The currently selected experience value |
67+
| `onChange` | (experience: string) => void | Yes | Callback when experience selection changes |
68+
69+
### ExperienceControl.Base
70+
71+
| Prop | Type | Required | Description |
72+
| ---------- | --------- | -------- | ------------------------------------- |
73+
| `label` | string | Yes | The label displayed above the control |
74+
| `children` | ReactNode | Yes | The button components to render |
75+
76+
### ExperienceControl.Option
77+
78+
| Prop | Type | Required | Description |
79+
| ------------ | ---------- | -------- | ----------------------------------------- |
80+
| `className` | string | No | Additional CSS class names |
81+
| `isSelected` | boolean | Yes | Whether this option is currently selected |
82+
| `onClick` | () => void | Yes | Click handler for the button |
83+
| `children` | ReactNode | Yes | The content to render inside the button |
84+
| `helpText` | string | No | Help text for the option |
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export const IconGood = () => {
2+
return (
3+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<g id="Icon">
5+
<path
6+
id="Shape"
7+
fill-rule="evenodd"
8+
clip-rule="evenodd"
9+
d="M5.99452 13.0808C6.89004 13.0808 7.616 12.3549 7.616 11.4594C7.616 10.5638 6.89004 9.83789 5.99452 9.83789C5.09901 9.83789 4.37305 10.5638 4.37305 11.4594C4.37305 12.3549 5.09901 13.0808 5.99452 13.0808ZM18.0054 13.0808C18.901 13.0808 19.6269 12.3549 19.6269 11.4594C19.6269 10.5638 18.901 9.83789 18.0054 9.83789C17.1099 9.83789 16.384 10.5638 16.384 11.4594C16.384 12.3549 17.1099 13.0808 18.0054 13.0808ZM15.352 14.5221C13.6377 16.7223 10.493 17.1864 8.84968 14.7405C8.49299 14.2091 8.01618 14.0562 7.41927 14.2819C7.31736 14.3183 7.23185 14.382 7.16269 14.473C6.71865 15.0517 6.73138 15.6213 7.2009 16.1818C9.90335 19.4193 14.604 19.3756 17.0007 15.8542C17.4411 15.21 17.3137 14.7132 16.6186 14.3638L16.4439 14.2764C16.0107 14.0581 15.6468 14.1399 15.352 14.5221Z"
10+
fill="#255C33"
11+
/>
12+
</g>
13+
</svg>
14+
);
15+
};
16+
17+
export const IconNeutral = () => {
18+
return (
19+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
20+
<g id="Icon">
21+
<path
22+
id="Shape"
23+
fill-rule="evenodd"
24+
clip-rule="evenodd"
25+
d="M5.99452 13.0808C6.89004 13.0808 7.616 12.3549 7.616 11.4594C7.616 10.5638 6.89004 9.83789 5.99452 9.83789C5.09901 9.83789 4.37305 10.5638 4.37305 11.4594C4.37305 12.3549 5.09901 13.0808 5.99452 13.0808ZM18.0054 13.0808C18.901 13.0808 19.6269 12.3549 19.6269 11.4594C19.6269 10.5638 18.901 9.83789 18.0054 9.83789C17.1099 9.83789 16.384 10.5638 16.384 11.4594C16.384 12.3549 17.1099 13.0808 18.0054 13.0808ZM7.57442 15.2678L16.4188 15.2832C16.9826 15.2842 17.4389 15.7421 17.438 16.3059L17.4377 16.4479C17.4367 17.0117 16.9788 17.468 16.415 17.467L7.57061 17.4516C7.00677 17.4506 6.55048 16.9927 6.55147 16.4289L6.55172 16.2869C6.5527 15.7231 7.01058 15.2668 7.57442 15.2678Z"
26+
fill="#2F2F2F"
27+
/>
28+
</g>
29+
</svg>
30+
);
31+
};
32+
33+
export const IconBad = () => {
34+
return (
35+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
36+
<g id="Icon">
37+
<path
38+
id="Shape"
39+
fill-rule="evenodd"
40+
clip-rule="evenodd"
41+
d="M5.99451 13.081C6.89002 13.081 7.61597 12.355 7.61597 11.4595C7.61597 10.564 6.89002 9.83801 5.99451 9.83801C5.09899 9.83801 4.37305 10.564 4.37305 11.4595C4.37305 12.355 5.09899 13.081 5.99451 13.081ZM18.0054 13.081C18.9009 13.081 19.6269 12.355 19.6269 11.4595C19.6269 10.564 18.9009 9.83801 18.0054 9.83801C17.1099 9.83801 16.384 10.564 16.384 11.4595C16.384 12.355 17.1099 13.081 18.0054 13.081ZM8.68587 17.0991C9.82145 15.3794 12.0708 14.7516 13.9052 15.7561C14.4784 16.0673 14.9043 16.5805 15.3137 17.0773C15.887 17.7652 16.7987 17.5577 17.099 16.717C17.169 16.5224 17.1866 16.3126 17.1503 16.1086C17.1139 15.9046 17.0247 15.7134 16.8915 15.5541C14.1181 12.2238 9.45021 12.2293 6.98797 15.8162C6.59489 16.3876 6.69497 16.8516 7.28824 17.2083L7.53939 17.3612C8.00527 17.6415 8.38742 17.5541 8.68587 17.0991Z"
42+
fill="#660C0D"
43+
/>
44+
</g>
45+
</svg>
46+
);
47+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Button } from '@wordpress/components';
2+
import clsx from 'clsx';
3+
import { IconBad, IconGood, IconNeutral } from './icons';
4+
5+
import './style.scss';
6+
7+
enum Experience {
8+
GOOD = 'good',
9+
NEUTRAL = 'neutral',
10+
BAD = 'bad',
11+
}
12+
13+
const ExperienceControlOption = ( {
14+
className,
15+
isSelected,
16+
onClick,
17+
children,
18+
helpText,
19+
}: {
20+
className?: string;
21+
isSelected: boolean;
22+
onClick: () => void;
23+
children: React.ReactNode;
24+
helpText?: string;
25+
} ) => (
26+
<div className="experience-control__option">
27+
<Button
28+
className={ clsx( 'experience-control__button', className, {
29+
'is-selected': isSelected,
30+
} ) }
31+
aria-label={ helpText }
32+
onClick={ onClick }
33+
>
34+
<div className="experience-control__button-content">{ children }</div>
35+
</Button>
36+
<div className="experience-control__option-help-text">{ helpText }</div>
37+
</div>
38+
);
39+
40+
const ExperienceControlBase = ( {
41+
label,
42+
children,
43+
}: {
44+
label: string;
45+
children: React.ReactNode;
46+
} ) => (
47+
<div className="experience-control">
48+
<div className="experience-control__label">{ label }</div>
49+
<div className="experience-control__buttons">{ children }</div>
50+
</div>
51+
);
52+
53+
export function ExperienceControl( {
54+
label,
55+
onChange,
56+
selectedExperience,
57+
}: {
58+
label: string;
59+
onChange: ( experience: string ) => void;
60+
selectedExperience: string;
61+
} ) {
62+
const handleChange = ( experience: string ) => {
63+
onChange( experience );
64+
};
65+
66+
const options = [
67+
{
68+
value: Experience.GOOD,
69+
icon: <IconGood />,
70+
},
71+
{
72+
value: Experience.NEUTRAL,
73+
icon: <IconNeutral />,
74+
},
75+
{
76+
value: Experience.BAD,
77+
icon: <IconBad />,
78+
},
79+
];
80+
81+
return (
82+
<ExperienceControlBase label={ label }>
83+
{ options.map( ( option ) => (
84+
<ExperienceControlOption
85+
key={ option.value }
86+
className={ `is-${ option.value }` }
87+
isSelected={ selectedExperience === option.value }
88+
onClick={ () => handleChange( option.value ) }
89+
>
90+
{ option.icon }
91+
</ExperienceControlOption>
92+
) ) }
93+
</ExperienceControlBase>
94+
);
95+
}
96+
ExperienceControl.Base = ExperienceControlBase;
97+
ExperienceControl.Option = ExperienceControlOption;
98+
99+
export default ExperienceControl;

0 commit comments

Comments
 (0)