Skip to content

Commit

Permalink
feat: upstream CustomSelect component
Browse files Browse the repository at this point in the history
Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Jan 6, 2025
1 parent 960cc42 commit 7cda5a4
Show file tree
Hide file tree
Showing 10 changed files with 1,091 additions and 0 deletions.
82 changes: 82 additions & 0 deletions src/components/CustomSelect/CustomSelect.scss
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;
}
}
143 changes: 143 additions & 0 deletions src/components/CustomSelect/CustomSelect.stories.tsx
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>&#128512;</div>,
text: "Smile",
disabled: false,
},
{
value: "grin",
label: <div>&#128513;</div>,
text: "Grin",
disabled: false,
},
{
value: "cry",
label: <div>&#128557;</div>,
text: "Cry",
disabled: false,
},
{
value: "angry",
label: <div>&#128545;</div>,
text: "Angry",
disabled: false,
},
{
value: "sad",
label: <div>&#128546;</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",
},
};
72 changes: 72 additions & 0 deletions src/components/CustomSelect/CustomSelect.test.tsx
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();
});
});
Loading

0 comments on commit 7cda5a4

Please sign in to comment.