Skip to content

Commit

Permalink
feat(slider): create slider (#1503)
Browse files Browse the repository at this point in the history
* feat(slider): init

* refactor(slider): add semantic html and story

* feat(slider): styling wip

* refactor(slider): remove datalist and options

* feat(slider): finish styling default slider

* refactor(slider): temp remove unnecessary disabled state

* refactor(slider): support not focus-visible

* test(slider): add no label story and snaps

* refactor(slider): shift and dry styles

* feat(slider): add cursor and disabled styling

* test(slider): add disabled and focus stories

* fix(slider): increase size and hitbox for axe

* feat(slider): add markers

* fix(slider): align markers to values

* test(slider): update snaps

* fix(slider): make marker color high enough contrast

* docs(slider): clean comments and strengthen type

* feat(slider): generate markers and more style disabled

* docs(slider): add helper fn docstring  and dry stories

* refactor(slider): move util func to util folder

* feat(slider): allow fieldnote as aria description
  • Loading branch information
jinlee93 authored Feb 28, 2023
1 parent 4389297 commit e7ced34
Show file tree
Hide file tree
Showing 9 changed files with 965 additions and 0 deletions.
168 changes: 168 additions & 0 deletions src/components/Slider/Slider.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
@import '../../design-tokens/mixins.css';

/*------------------------------------*\
# SLIDER
\*------------------------------------*/

/**
* Slider wrapping Label and Input
*/
.slider {
--slider-track-height: var(--eds-size-1);
--slider-thumb-size: var(--eds-size-3);

display: flex;
flex-direction: column;
gap: var(--eds-size-1-and-half);
}

/**
* Slider label disabled
*/
.slider__label--disabled {
color: var(--eds-theme-color-text-disabled);
}

/**
* Slider Input
*/
.slider__input {
/* increases vertical hitbox for target size accessibility */
padding-top: 22px;
padding-bottom: 22px;
/* fills left side of track as a percentage of the input value */
--slider-track-background: linear-gradient(
/* fill from left to right */ to right,
/* fill color */ var(--eds-theme-color-background-brand-primary-strong)
/* percent to fill */ calc(var(--ratio) * 100%),
/* unfilled color */ var(--eds-theme-color-background-neutral-medium)
/* don't blend the colors */ 0
);

appearance: none;
background: transparent;

height: var(--slider-thumb-size);
}
.slider__input:focus {
outline: none;
}
.slider__input:disabled {
--slider-track-background: linear-gradient(
/* fill from left to right */ to right,
/* fill color */ var(--eds-theme-color-background-disabled)
/* percent to fill */ calc(var(--ratio) * 100%),
/* unfilled color */ var(--eds-theme-color-background-neutral-medium)
/* don't blend the colors */ 0
);
cursor: not-allowed;
}

/*
* Chrome, Safari, Edge Chromium
* Although redundant with Firefox, has to be separate or else Chrome ignores
*/
.slider__input::-webkit-slider-runnable-track {
background: var(--slider-track-background);
height: var(--slider-track-height);
border-radius: var(--eds-border-radius-full);
}
/**
* Slider Input Track
*/
/* Firefox */
.slider__input::-moz-range-track {
background: var(--slider-track-background);
height: var(--slider-track-height);
border-radius: var(--eds-border-radius-full);
}

/* Chrome, Safari, Edge Chromium */
.slider__input::-webkit-slider-thumb {
appearance: none;

height: var(--slider-thumb-size);
width: var(--slider-thumb-size);
background: var(--eds-theme-color-text-neutral-default-inverse);
border: var(--eds-border-width-md) solid
var(--eds-theme-color-border-neutral-default);
border-radius: var(--eds-border-radius-full);

margin-top: calc(
var(--slider-track-height) / 2 - var(--slider-thumb-size) / 2
); /* Centers thumb on the track */
}
.slider__input:not(:disabled)::-webkit-slider-thumb {
cursor: grab;
}
.slider__input:not(:disabled)::-webkit-slider-thumb:active {
cursor: grabbing;
}
/* Chrome, Safari, Edge Chromium Focus */
.slider__input:focus-visible::-webkit-slider-thumb {
@mixin focus;
}
@supports not selector(:focus-visible) {
.slider__input:focus::-webkit-slider-thumb {
@mixin focus;
}
}

/*
* Slider Input Thumb
*/
/* Firefox */
.slider__input::-moz-range-thumb {
box-sizing: border-box;

height: var(--slider-thumb-size);
width: var(--slider-thumb-size);
background: var(--eds-theme-color-text-neutral-default-inverse);
border: var(--eds-border-width-md) solid
var(--eds-theme-color-border-neutral-default);
border-radius: var(--eds-border-radius-full);
}
.slider__input:not(:disabled)::-moz-range-thumb {
cursor: grab;
}
.slider__input:not(:disabled)::-moz-range-thumb:active {
cursor: grabbing;
}
/* Firefox Focus */
.slider__input:focus-visible::-moz-range-thumb {
@mixin focus;
}
@supports not selector(:focus-visible) {
.slider__input:focus::-moz-range-thumb {
@mixin focus;
}
}

/**
* Slider Markers wrapper below the track
*/
.slider__markers {
display: flex;
align-items: center;
justify-content: space-between;

/* Calculates offset of the markers to align with actual values */
padding-left: calc(var(--slider-thumb-size) / 2);
padding-right: calc(var(--slider-thumb-size) / 2);
}

/**
* Slider Marker
*/
.slider__marker {
@mixin eds-theme-typography-caption-text-sm;

/* Centers the text to the marker location */
width: 0px;
display: flex;
justify-content: center;
}

.slider__marker--disabled {
color: var(--eds-theme-color-text-disabled);
}
143 changes: 143 additions & 0 deletions src/components/Slider/Slider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { BADGE } from '@geometricpanda/storybook-addon-badges';
import type { StoryObj, Meta } from '@storybook/react';
import { userEvent } from '@storybook/testing-library';
import React, { useState } from 'react';

import { Slider } from './Slider';

export default {
title: 'Components/Slider',
component: Slider,
parameters: {
layout: 'centered',
badges: [BADGE.BETA],
},
decorators: [
(Story) => (
<div className="w-96">
<Story />
</div>
),
],
render: (args) => <InteractiveSlider {...args} />,
} as Meta<Args>;

const InteractiveSlider = ({
min = 0,
max = 100,
step = 1,
value = 50,
...args
}: Args) => {
const [sliderValue, setSliderValue] = useState(value);
return (
<Slider
max={max}
min={min}
step={step}
{...args}
onChange={(e) => setSliderValue(Number(e.target.value))}
value={sliderValue}
/>
);
};

type Args = React.ComponentProps<typeof Slider>;

export const Default: StoryObj<Args> = {
args: {
label: 'Slider Label',
},
};

export const NoVisibleLabel: StoryObj<Args> = {
args: {
'aria-label': 'Not visible slider label',
},
};

export const GeneratedMarkers: StoryObj<Args> = {
args: {
label: 'Slider Label',
min: 1,
max: 5,
value: 3,
step: 1,
markers: 'number',
},
};

export const NegativeNonIntegerMarkers: StoryObj<Args> = {
args: {
label: 'Slider Label',
min: -1,
max: 1,
value: 0,
step: 0.5,
markers: 'number',
},
};

export const Disabled: StoryObj<Args> = {
args: {
label: 'Slider Label',
min: 1,
max: 5,
value: 3,
step: 1,
markers: 'number',
disabled: true,
},
};

export const MarkersSmallValues: StoryObj<Args> = {
args: {
label: 'Slider Label',
min: 1,
max: 5,
value: 3,
markers: ['1', '2', '3', '4', '5'],
},
};

export const MarkersLargeValues: StoryObj<Args> = {
args: {
label: 'Slider Label',
min: 0,
max: 10000,
value: 5000,
step: 2500,
markers: 'number',
},
decorators: [
(Story) => (
<div className="w-80">
<Story />
</div>
),
],
};

export const FieldNote: StoryObj<Args> = {
args: {
label: 'Slider Label',
fieldNote: 'This is a fieldnote. It overrides the markers',
markers: 'number',
},
};

// For visual regression test
export const Focus: StoryObj<Args> = {
args: {
label: 'Slider Label',
},
parameters: {
/**
* No point snapping the button as this story is testing visual regression on the focus state (snap no difference than Default story).
*/
snapshot: { skip: true },
},
play: () => {
userEvent.tab();
},
};
36 changes: 36 additions & 0 deletions src/components/Slider/Slider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { generateSnapshots } from '@chanzuckerberg/story-utils';
import { render } from '@testing-library/react';
import React from 'react';
import * as stories from './Slider.stories';
import Slider from './';

describe('<Slider />', () => {
generateSnapshots(stories);
describe('error throws', () => {
// expect console error from react, suppressed.
const consoleErrorMock = jest.spyOn(console, 'error');
consoleErrorMock.mockImplementation();
it('throws an error if no label or aria-label', () => {
expect(() => {
render(<Slider max={5} min={0} step={1} value={2} />);
}).toThrow(/You must provide a visible label or aria-label/);
});
it('throws an error if told to generate markers, but steps are not integers', () => {
expect(() => {
render(
<Slider
label="Test"
markers="number"
max={5}
min={0}
step={2}
value={2}
/>,
);
}).toThrow(
/Number of markers is not an integer. Change step or supply custom markers/,
);
});
consoleErrorMock.mockRestore();
});
});
Loading

0 comments on commit e7ced34

Please sign in to comment.