-
Notifications
You must be signed in to change notification settings - Fork 59
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: upstream CustomSelect component
Signed-off-by: Mason Hu <[email protected]>
- Loading branch information
Showing
10 changed files
with
1,091 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
@use "sass:map"; | ||
@import "vanilla-framework"; | ||
@include vf-b-placeholders; // Vanilla base placeholders to extend from | ||
|
||
.p-custom-select { | ||
@include vf-b-forms; | ||
|
||
// style copied directly from vanilla-framework for the select element | ||
.p-custom-select__toggle { | ||
@include vf-icon-chevron-themed; | ||
@extend %vf-input-elements; | ||
|
||
// stylelint-disable property-no-vendor-prefix | ||
-moz-appearance: none; | ||
-webkit-appearance: none; | ||
appearance: none; | ||
// stylelint-enable property-no-vendor-prefix | ||
background-position: right calc(map-get($grid-margin-widths, default) / 2) | ||
center; | ||
background-repeat: no-repeat; | ||
background-size: map-get($icon-sizes, default); | ||
border-top: none; | ||
box-shadow: none; | ||
min-height: map-get($line-heights, default-text); | ||
padding-right: calc($default-icon-size + 2 * $sph--small); | ||
text-indent: 0.01px; | ||
|
||
&:hover { | ||
cursor: pointer; | ||
} | ||
|
||
// this emulates the highlight effect when the select is focused | ||
// without crowding the content with a border | ||
&.active, | ||
&:focus { | ||
box-shadow: inset 0 0 0 3px $color-focus; | ||
} | ||
|
||
.toggle-label { | ||
display: flow-root; | ||
text-align: left; | ||
width: 100%; | ||
} | ||
} | ||
} | ||
|
||
.p-custom-select__dropdown { | ||
background-color: $colors--theme--background-alt; | ||
box-shadow: $box-shadow--deep; | ||
outline: none; | ||
position: relative; | ||
|
||
.p-custom-select__option { | ||
background-color: $colors--theme--background-alt; | ||
font-weight: $font-weight-regular-text; | ||
padding: $sph--x-small $sph--small; | ||
|
||
&.highlight { | ||
// browser default styling for options when hovered | ||
background-color: #06c; | ||
cursor: pointer; | ||
|
||
// make sure that if an option is highlighted, its text is white for good contrast | ||
* { | ||
color: white; | ||
} | ||
} | ||
} | ||
|
||
.p-custom-select__search { | ||
background-color: $colors--theme--background-alt; | ||
padding: $sph--x-small; | ||
padding-bottom: $sph--small; | ||
position: sticky; | ||
top: 0; | ||
} | ||
|
||
.p-list { | ||
max-height: 30rem; | ||
overflow: auto; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import { Meta, StoryObj } from "@storybook/react/*"; | ||
import CustomSelect from "./CustomSelect"; | ||
import React, { ComponentProps, useState } from "react"; | ||
import { CustomSelectOption } from "."; | ||
|
||
type StoryProps = ComponentProps<typeof CustomSelect>; | ||
|
||
const generateStandardOptions = (num: number): CustomSelectOption[] => | ||
Array(num) | ||
.fill(null) | ||
.map((_, i) => ({ | ||
value: `option-${i + 1}`, | ||
label: `Option ${i + 1}`, | ||
text: `Option ${i + 1}`, | ||
disabled: false, | ||
})); | ||
|
||
const generateCustomOptions = (): CustomSelectOption[] => { | ||
return [ | ||
{ | ||
value: "smile", | ||
label: <div>😀</div>, | ||
text: "Smile", | ||
disabled: false, | ||
}, | ||
{ | ||
value: "grin", | ||
label: <div>😁</div>, | ||
text: "Grin", | ||
disabled: false, | ||
}, | ||
{ | ||
value: "cry", | ||
label: <div>😭</div>, | ||
text: "Cry", | ||
disabled: false, | ||
}, | ||
{ | ||
value: "angry", | ||
label: <div>😡</div>, | ||
text: "Angry", | ||
disabled: false, | ||
}, | ||
{ | ||
value: "sad", | ||
label: <div>😢</div>, | ||
text: "Sad", | ||
disabled: false, | ||
}, | ||
]; | ||
}; | ||
|
||
const Template = ({ ...props }: StoryProps) => { | ||
const [selected, setSelected] = useState<string>(props.value || ""); | ||
return ( | ||
<CustomSelect | ||
{...props} | ||
value={selected} | ||
onChange={(value) => setSelected(value)} | ||
/> | ||
); | ||
}; | ||
|
||
const meta: Meta<StoryProps> = { | ||
component: CustomSelect, | ||
render: Template, | ||
tags: ["autodocs"], | ||
args: { | ||
name: "customSelect", | ||
label: "Custom Select", | ||
searchable: "auto", | ||
initialPosition: "left", | ||
}, | ||
argTypes: { | ||
searchable: { | ||
options: ["auto", "always", "never"], | ||
control: { | ||
type: "select", | ||
}, | ||
}, | ||
initialPosition: { | ||
options: ["left", "right"], | ||
control: { | ||
type: "select", | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
export default meta; | ||
|
||
type Story = StoryObj<StoryProps>; | ||
|
||
/** | ||
* If `label` is of `string` type. You do not have to do anything extra to render it. | ||
*/ | ||
export const StandardOptions: Story = { | ||
args: { | ||
options: generateStandardOptions(10), | ||
}, | ||
}; | ||
|
||
/** | ||
* If `label` is of `ReactNode` type. You can render custom content. | ||
* In this case, the `text` property for each option is required and is used for display in the toggle, search and sort functionalities. | ||
*/ | ||
export const CustomOptions: Story = { | ||
args: { | ||
options: generateCustomOptions(), | ||
}, | ||
}; | ||
|
||
/** | ||
* For each option, if `disable` is set to `true`, the option will be disabled. | ||
*/ | ||
export const DisabledOptions: Story = { | ||
args: { | ||
options: generateStandardOptions(5).map((option, i) => ({ | ||
...option, | ||
disabled: i % 2 === 0, | ||
})), | ||
}, | ||
}; | ||
|
||
/** | ||
* Search is enabled by default when there are 5 or more options. | ||
*/ | ||
export const AutoSearchable: Story = { | ||
args: { | ||
options: generateStandardOptions(5), | ||
searchable: "auto", | ||
}, | ||
}; | ||
|
||
/** | ||
* Search can be enabled manually by setting `searchable` to `always`. | ||
*/ | ||
export const ManualSearchable: Story = { | ||
args: { | ||
options: generateStandardOptions(4), | ||
searchable: "always", | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { render, screen, fireEvent } from "@testing-library/react"; | ||
import CustomSelect from "./CustomSelect"; | ||
import { CustomSelectOption } from "./CustomSelectDropdown"; | ||
import React from "react"; | ||
|
||
// Global mocks | ||
window.HTMLElement.prototype.scrollIntoView = jest.fn(); | ||
|
||
describe("CustomSelect", () => { | ||
const mockOnChange = jest.fn(); | ||
const options: CustomSelectOption[] = [ | ||
{ value: "1", label: "Option 1" }, | ||
{ value: "2", label: "Option 2" }, | ||
]; | ||
|
||
afterEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
it("renders", () => { | ||
render( | ||
<CustomSelect | ||
data-testid="test-select" | ||
name="test-select" | ||
label="Test Select" | ||
options={options} | ||
value="1" | ||
onChange={mockOnChange} | ||
/>, | ||
); | ||
expect(screen.getByTestId("test-select")).toMatchSnapshot(); | ||
}); | ||
|
||
it("renders the CustomSelectDropdown when clicked", () => { | ||
render( | ||
<CustomSelect | ||
name="test-select" | ||
options={options} | ||
value="1" | ||
onChange={mockOnChange} | ||
/>, | ||
); | ||
|
||
const toggleButton = screen.getByRole("button"); | ||
fireEvent.click(toggleButton); | ||
|
||
options.forEach((option) => { | ||
expect( | ||
screen.getByRole("option", { name: option.label as string }), | ||
).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it("calls onChange when an option is selected and closes the dropdown", () => { | ||
render( | ||
<CustomSelect | ||
name="test-select" | ||
options={options} | ||
value="1" | ||
onChange={mockOnChange} | ||
/>, | ||
); | ||
|
||
const toggleButton = screen.getByRole("button"); | ||
fireEvent.click(toggleButton); | ||
fireEvent.click(screen.getByRole("option", { name: "Option 2" })); | ||
|
||
expect(mockOnChange).toHaveBeenCalledWith("2"); | ||
const option = screen.queryByRole("option", { name: "Option 2" }); | ||
expect(option).not.toBeInTheDocument(); | ||
}); | ||
}); |
Oops, something went wrong.