Просмотр исходного кода

feat(ui): Add custom styling to Checkbox component (#42583)

Ref https://github.com/getsentry/team-coreui/issues/29

- Add custom styling to `Checkbox` by using an invisible `input` and
visual aria-hidden checkbox (built similarly to the [material UI
checkbox component](https://mui.com/material-ui/react-checkbox/))
- Fixes downstream styling issues stemming from the `input` being
wrapped. In doing so, I removed a global CSS style where a `margin` was
being added to `input[type="checkbox"]`, which obviated the need for a
lot of `& input { margin: 0 }` styles

Co-authored-by: Vu Luong <vuluongj20@gmail.com>
Malachi Willey 2 лет назад
Родитель
Сommit
5e976c0efb

+ 70 - 0
docs-ui/stories/components/checkbox.stories.js

@@ -0,0 +1,70 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import Checkbox from 'sentry/components/checkbox';
+
+export default {
+  title: 'Components/Forms/Controls/Checkbox',
+  component: Checkbox,
+  args: {
+    size: 'sm',
+    disabled: false,
+    checked: true,
+  },
+  argTypes: {
+    size: {
+      options: ['xs', 'sm', 'md'],
+      control: {type: 'radio'},
+    },
+    disabled: {
+      control: {type: 'boolean'},
+    },
+  },
+};
+
+export const Default = props => {
+  const [checked, setChecked] = useState(true);
+
+  return (
+    <div>
+      <Checkbox
+        {...props}
+        checked={checked}
+        onChange={e => setChecked(e.target.checked)}
+      />
+    </div>
+  );
+};
+
+export const WithLabel = props => {
+  const [check1, setCheck1] = useState(true);
+  const [check2, setCheck2] = useState(false);
+
+  return (
+    <div>
+      <Label>
+        Label to left
+        <Checkbox
+          {...props}
+          checked={check1}
+          onChange={e => setCheck1(e.target.checked)}
+        />
+      </Label>
+      <Label>
+        <Checkbox
+          {...props}
+          checked={check2}
+          onChange={e => setCheck2(e.target.checked)}
+        />
+        Label to right
+      </Label>
+    </div>
+  );
+};
+
+const Label = styled('label')`
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  margin-bottom: 2rem;
+`;

+ 54 - 5
static/app/components/checkbox.spec.tsx

@@ -1,12 +1,61 @@
-import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {useState} from 'react';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import Checkbox from 'sentry/components/checkbox';
 
 describe('Checkbox', function () {
-  it('renders', async function () {
-    const {container} = render(<Checkbox onChange={() => {}} />);
+  const defaultProps = {
+    checked: false,
+    onChange: jest.fn(),
+  };
+
+  describe('snapshots', function () {
+    it('unchecked state', async function () {
+      const {container} = render(<Checkbox {...defaultProps} />);
+
+      expect(await screen.findByRole('checkbox')).toBeInTheDocument();
+      expect(container).toSnapshot();
+    });
+
+    it('checked state', async function () {
+      const {container} = render(<Checkbox {...defaultProps} checked />);
+
+      expect(await screen.findByRole('checkbox')).toBeInTheDocument();
+      expect(container).toSnapshot();
+    });
+
+    it('indeterminate state', async function () {
+      const {container} = render(<Checkbox {...defaultProps} checked="indeterminate" />);
+
+      expect(await screen.findByRole('checkbox')).toBeInTheDocument();
+      expect(container).toSnapshot();
+    });
+  });
+
+  describe('behavior', function () {
+    function CheckboxWithLabel() {
+      const [checked, setChecked] = useState(false);
+
+      return (
+        <label>
+          <Checkbox
+            checked={checked}
+            onChange={e => {
+              setChecked(e.target.checked);
+            }}
+          />
+          Label text
+        </label>
+      );
+    }
+
+    it('toggles on click', function () {
+      render(<CheckboxWithLabel />);
 
-    expect(await screen.findByRole('checkbox')).toBeInTheDocument();
-    expect(container).toSnapshot();
+      expect(screen.getByRole('checkbox')).not.toBeChecked();
+      userEvent.click(screen.getByLabelText('Label text'));
+      expect(screen.getByRole('checkbox')).toBeChecked();
+    });
   });
 });

+ 82 - 8
static/app/components/checkbox.tsx

@@ -1,15 +1,32 @@
 import {useEffect, useRef} from 'react';
+import styled from '@emotion/styled';
+
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {IconCheckmark, IconSubtract} from 'sentry/icons';
+import {FormSize} from 'sentry/utils/theme';
 
 type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement>;
 
-interface Props extends Omit<CheckboxProps, 'checked'> {
+interface Props extends Omit<CheckboxProps, 'checked' | 'size'> {
   /**
    * Is the checkbox active? Supports 'indeterminate'
    */
   checked?: CheckboxProps['checked'] | 'indeterminate';
+  /**
+   *
+   */
+  size?: FormSize;
 }
 
-const Checkbox = ({checked = false, ...props}: Props) => {
+type CheckboxConfig = {borderRadius: string; box: string; icon: string};
+
+const checkboxSizeMap: Record<FormSize, CheckboxConfig> = {
+  xs: {box: '12px', icon: '10px', borderRadius: '2px'},
+  sm: {box: '16px', icon: '12px', borderRadius: '4px'},
+  md: {box: '22px', icon: '16px', borderRadius: '6px'},
+};
+
+const Checkbox = ({checked = false, size = 'sm', ...props}: Props) => {
   const checkboxRef = useRef<HTMLInputElement>(null);
 
   // Support setting the indeterminate value, which is only possible through
@@ -21,13 +38,70 @@ const Checkbox = ({checked = false, ...props}: Props) => {
   }, [checked]);
 
   return (
-    <input
-      ref={checkboxRef}
-      checked={checked !== 'indeterminate' && checked}
-      type="checkbox"
-      {...props}
-    />
+    <Wrapper {...{checked, size}}>
+      <HiddenInput
+        checked={checked !== 'indeterminate' && checked}
+        type="checkbox"
+        {...props}
+      />
+      <StyledCheckbox aria-hidden checked={checked} size={size}>
+        {checked === true && <IconCheckmark size={checkboxSizeMap[size].icon} />}
+        {checked === 'indeterminate' && (
+          <IconSubtract size={checkboxSizeMap[size].icon} />
+        )}
+      </StyledCheckbox>
+      <InteractionStateLayer
+        higherOpacity={checked === true || checked === 'indeterminate'}
+      />
+    </Wrapper>
   );
 };
 
+const Wrapper = styled('div')<{checked: Props['checked']; size: FormSize}>`
+  position: relative;
+  cursor: pointer;
+  display: inline-flex;
+  justify-content: flex-start;
+  color: ${p => (p.checked ? p.theme.white : p.theme.textColor)};
+  border-radius: ${p => checkboxSizeMap[p.size].borderRadius};
+`;
+
+const HiddenInput = styled('input')`
+  position: absolute;
+  opacity: 0;
+  height: 100%;
+  width: 100%;
+  top: 0;
+  left: 0;
+  margin: 0;
+  cursor: pointer;
+
+  &.focus-visible + * {
+    box-shadow: ${p => p.theme.focusBorder} 0 0 0 2px;
+  }
+
+  &:disabled + * {
+    background: ${p => (p.checked ? p.theme.disabled : p.theme.backgroundSecondary)};
+    border-color: ${p => (p.checked ? p.theme.disabled : p.theme.disabledBorder)};
+  }
+`;
+
+const StyledCheckbox = styled('div')<{
+  checked: Props['checked'];
+  size: FormSize;
+}>`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: inherit;
+  box-shadow: ${p => p.theme.dropShadowLight} inset;
+  width: ${p => checkboxSizeMap[p.size].box};
+  height: ${p => checkboxSizeMap[p.size].box};
+  border-radius: ${p => checkboxSizeMap[p.size].borderRadius};
+  background: ${p => (p.checked ? p.theme.active : p.theme.background)};
+  border: 1px solid ${p => (p.checked ? p.theme.active : p.theme.gray200)};
+  pointer-events: none;
+`;
+
 export default Checkbox;

+ 6 - 2
static/app/components/forms/controls/multipleCheckbox.tsx

@@ -1,6 +1,8 @@
 import {useCallback} from 'react';
 import styled from '@emotion/styled';
 
+import Checkbox from 'sentry/components/checkbox';
+import space from 'sentry/styles/space';
 import {Choices} from 'sentry/types';
 import {defined} from 'sentry/utils';
 
@@ -10,6 +12,8 @@ const MultipleCheckboxWrapper = styled('div')`
 `;
 
 const Label = styled('label')`
+  display: inline-flex;
+  align-items: center;
   font-weight: normal;
   white-space: nowrap;
   margin-right: 10px;
@@ -18,7 +22,7 @@ const Label = styled('label')`
 `;
 
 const CheckboxLabel = styled('span')`
-  margin-left: 3px;
+  margin-left: ${space(1)};
 `;
 
 type SelectedValue = (string | number)[];
@@ -55,7 +59,7 @@ function MultipleCheckbox({choices, value, disabled, onChange}: Props) {
       {choices.map(([choiceValue, choiceLabel]) => (
         <LabelContainer key={choiceValue}>
           <Label>
-            <input
+            <Checkbox
               type="checkbox"
               value={choiceValue}
               onChange={e => handleChange(choiceValue, e)}

+ 0 - 4
static/app/components/forms/fields/checkboxField.tsx

@@ -88,10 +88,6 @@ const ControlWrapper = styled('span')`
   align-self: flex-start;
   display: flex;
   margin-right: ${space(1)};
-
-  & input {
-    margin: 0;
-  }
 `;
 
 const FieldLayout = styled('div')`

+ 2 - 7
static/app/components/organizations/timeRangeSelector/dateRange/index.tsx

@@ -204,13 +204,7 @@ class BaseDateRange extends Component<Props, State> {
             />
             <UtcPicker>
               {t('Use UTC')}
-              <Checkbox
-                onChange={onChangeUtc}
-                checked={utc || false}
-                style={{
-                  margin: '0 0 0 0.5em',
-                }}
-              />
+              <Checkbox onChange={onChangeUtc} checked={utc || false} />
             </UtcPicker>
           </TimeAndUtcPicker>
         )}
@@ -239,6 +233,7 @@ const UtcPicker = styled('div')`
   align-items: center;
   justify-content: flex-end;
   flex: 1;
+  gap: ${space(1)};
 `;
 
 export default DateRange;

+ 1 - 5
static/app/components/smartSearchBar/searchBarDatePicker.tsx

@@ -209,11 +209,7 @@ const UtcPickerLabel = styled('label')`
   font-weight: normal;
   user-select: none;
   cursor: pointer;
-
-  input {
-    margin: 0 0 0 0.5em;
-    cursor: pointer;
-  }
+  gap: ${space(1)};
 `;
 
 export default SearchBarDatePicker;

+ 0 - 5
static/app/components/stream/group.tsx

@@ -543,11 +543,6 @@ const GroupCheckBoxWrapper = styled('div')`
   height: 15px;
   display: flex;
   align-items: center;
-
-  & input[type='checkbox'] {
-    margin: 0;
-    display: block;
-  }
 `;
 
 const primaryStatStyle = (theme: Theme) => css`

+ 2 - 4
static/app/views/issueList/actions/index.tsx

@@ -345,12 +345,10 @@ const StyledFlex = styled('div')`
 `;
 
 const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>`
+  display: flex;
+  align-items: center;
   padding-left: ${space(2)};
   margin-bottom: 1px;
-  & input[type='checkbox'] {
-    margin: 0;
-    display: block;
-  }
   ${p => p.isReprocessingQuery && 'flex: 1'};
 `;
 

+ 0 - 5
static/app/views/organizationGroupDetails/groupMerged/mergedItem.tsx

@@ -162,11 +162,6 @@ const ActionWrapper = styled('div')`
   grid-auto-flow: column;
   align-items: center;
   gap: ${space(1)};
-
-  /* Can't use styled components for this because of broad selector */
-  input[type='checkbox'] {
-    margin: 0;
-  }
 `;
 
 const Controls = styled('div')<{expanded: boolean}>`

Некоторые файлы не были показаны из-за большого количества измененных файлов