Skip to content

Commit

Permalink
feat: select component (#299)
Browse files Browse the repository at this point in the history
### Description
- Adds `<Select/>` component. A simpler version of `<Combobox/>` that more closely matches the browser `<select/>` element.
- Support the `multiple` prop like the browser `<select/>`
- Fixes [KNO-6849](https://linear.app/knock/issue/KNO-6849/[telegraph]-allow-combobox-labels-to-be-react-nodes) so that we can pass react nodes to the label.
- Fixes [KNO-6934](https://linear.app/knock/issue/KNO-6934/[telegraph]-allow-disabled-state-without-type-error-on-combobox) so that we can pass the `disabled` prop to the select component and combobox component.

### Tasks
[KNO-7249](https://linear.app/knock/issue/KNO-7249/[telegraph]-select-component)
[KNO-6934](https://linear.app/knock/issue/KNO-6934/[telegraph]-allow-disabled-state-without-type-error-on-combobox)
[KNO-6849](https://linear.app/knock/issue/KNO-6849/[telegraph]-allow-combobox-labels-to-be-react-nodes)
  • Loading branch information
kylemcd authored Oct 29, 2024
1 parent 9c836f9 commit 57d486e
Show file tree
Hide file tree
Showing 16 changed files with 378 additions and 23 deletions.
6 changes: 6 additions & 0 deletions .changeset/mean-roses-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@telegraph/combobox": patch
"@telegraph/select": patch
---

new select component + combobox fixes
2 changes: 1 addition & 1 deletion packages/combobox/src/Combobox/Combobox.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type DefinedOption = { value: string; label?: string };
export type DefinedOption = { value: string; label?: string | React.ReactNode };
export type Option = DefinedOption | undefined;
export const isMultiSelect = (
value: Option | Array<Option>,
Expand Down
8 changes: 8 additions & 0 deletions packages/combobox/src/Combobox/Combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ describe("Combobox", () => {
await user.click(clearButton!);
expect(trigger?.textContent).toBe("");
});
it("should not be able to open when disabled", async () => {
const user = userEvent.setup();
const { container } = render(<ComboboxSingleSelect disabled />);
const trigger = container.querySelector("[data-tgph-combobox-trigger]");
await user.click(trigger!);
await waitFor(() => trigger?.getAttribute("aria-expanded") === "false");
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
});
});
describe("Multi Select", () => {
it("combobox is accessible", async () => {
Expand Down
45 changes: 25 additions & 20 deletions packages/combobox/src/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,14 @@ type MultiSelect = {
onValueChange?: (value: Array<Option>) => void;
};

type RootProps = (
| {
value?: MultiSelect["value"];
onValueChange?: MultiSelect["onValueChange"];
layout?: "truncate" | "wrap";
}
| {
value?: SingleSelect["value"];
onValueChange?: SingleSelect["onValueChange"];
layout?: never;
}
) & {
type LayoutValue<O extends Option | Array<Option>> = O extends Option
? never
: "truncate" | "wrap";

type RootProps<O extends Option | Array<Option>> = {
value?: O;
onValueChange?: (value: O) => void;
layout?: LayoutValue<O>;
open?: boolean;
defaultOpen?: boolean;
errored?: boolean;
Expand All @@ -60,11 +56,12 @@ type RootProps = (
modal?: boolean;
closeOnSelect?: boolean;
clearable?: boolean;
disabled?: boolean;
children?: React.ReactNode;
};

const ComboboxContext = React.createContext<
Omit<RootProps, "children"> & {
Omit<RootProps<Option | Array<Option>>, "children"> & {
contentId: string;
triggerId: string;
open: boolean;
Expand All @@ -77,19 +74,22 @@ const ComboboxContext = React.createContext<
contentRef?: React.RefObject<HTMLDivElement>;
}
>({
value: undefined,
onValueChange: () => {},
contentId: "",
triggerId: "",
open: false,
setOpen: () => {},
onOpenToggle: () => {},
clearable: false,
disabled: false,
});

const Root = ({
const Root = <O extends Option | Array<Option>>({
modal = true,
closeOnSelect = true,
clearable = false,
disabled = false,
open: openProp,
onOpenChange: onOpenChangeProp,
defaultOpen: defaultOpenProp,
Expand All @@ -99,7 +99,7 @@ const Root = ({
placeholder,
layout,
...props
}: RootProps) => {
}: RootProps<O>) => {
const contentId = React.useId();
const triggerId = React.useId();
const triggerRef = React.useRef(null);
Expand Down Expand Up @@ -131,13 +131,17 @@ const Root = ({
contentId,
triggerId,
value,
onValueChange,
// Need to cast this to avoid type errors
// because the type of onValueChange is not
// consistent with the value type
onValueChange: onValueChange as (value: Option | Array<Option>) => void,
placeholder,
open,
setOpen,
onOpenToggle,
closeOnSelect,
clearable,
disabled,
searchQuery,
setSearchQuery,
triggerRef,
Expand All @@ -158,8 +162,8 @@ const Root = ({
};

type TriggerTagProps = {
value: string;
label?: string;
value: DefinedOption["value"];
label?: DefinedOption["label"];
};

const TriggerTag = ({ label, value, ...props }: TriggerTagProps) => {
Expand Down Expand Up @@ -401,6 +405,7 @@ const Trigger = ({ size = "2", ...props }: TriggerProps) => {
// Custom attributes
data-tgph-combobox-trigger
data-tgph-comobox-trigger-open={context.open}
disabled={context.disabled}
>
<TriggerValue />
<Stack align="center" gap="1">
Expand Down Expand Up @@ -618,8 +623,8 @@ const Options = <T extends TgphElement>({ ...props }: OptionsProps<T>) => {
type OptionProps<T extends TgphElement> = TgphComponentProps<
typeof TelegraphMenu.Button<T>
> & {
value: string;
label?: string;
value: DefinedOption["value"];
label?: DefinedOption["label"];
selected?: boolean | null;
};

Expand Down
10 changes: 10 additions & 0 deletions packages/select/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: [
"@knocklabs/eslint-config/library.js",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
};
17 changes: 17 additions & 0 deletions packages/select/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
![Telegraph by Knock](https://github.com/knocklabs/telegraph/assets/29106675/9b5022e3-b02c-4582-ba57-3d6171e45e44)

[![npm version](https://img.shields.io/npm/v/@telegraph/button.svg)](https://www.npmjs.com/package/@telegraph/select)

# @telegraph/select
> A simple select component built on top of @telegraph/combobox
## Installation Instructions

```
npm install @telegraph/select
```

### Add stylesheet
```
@import "@telegraph/select"
```
53 changes: 53 additions & 0 deletions packages/select/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@telegraph/select",
"version": "0.0.0",
"description": "A simple select component built on top of @telegraph/combobox",
"repository": "https://github.com/knocklabs/telegraph/tree/main/packages/select",
"author": "@knocklabs",
"license": "MIT",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
}
},
"files": [
"dist",
"README.md"
],
"prettier": "@telegraph/prettier-config",
"scripts": {
"clean": "rm -rf dist",
"dev": "vite build --watch --emptyOutDir false",
"build": "yarn clean && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier \"src/**/*.{js,ts,tsx}\" --write",
"format:check": "prettier \"src/**/*.{js,ts,tsx}\" --check",
"preview": "vite preview"
},
"dependencies": {
"@telegraph/combobox": "workspace:*",
"@telegraph/helpers": "workspace:*"
},
"devDependencies": {
"@knocklabs/eslint-config": "^0.0.3",
"@knocklabs/typescript-config": "^0.0.2",
"@telegraph/postcss-config": "workspace:^",
"@telegraph/prettier-config": "workspace:^",
"@telegraph/vite-config": "workspace:^",
"@types/react": "^18.2.48",
"eslint": "^8.56.0",
"react": "^18.2.0",
"react-dom": "^18.3.1",
"typescript": "^5.5.4",
"vite": "^5.3.6"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
5 changes: 5 additions & 0 deletions packages/select/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import pkg from "@telegraph/postcss-config";

const { styleEnginePostCssConfig } = pkg;

export default styleEnginePostCssConfig;
72 changes: 72 additions & 0 deletions packages/select/src/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";

import { Select } from "./Select";

const meta = {
title: "Components/Select",
component: Select.Root,
tags: ["autodocs"],
argTypes: {
size: {
options: ["0", "1", "2", "3"],
control: {
type: "select",
},
},
disabled: {
control: {
type: "boolean",
},
},
},
args: {
size: "2",
disabled: false,
},
} satisfies Meta<typeof Select.Root>;

type Story = StoryObj<typeof meta>;

export default meta;

export const SingleSelect: Story = {
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [value, setValue] = React.useState<string | undefined>(undefined);
return (
<Select.Root
placeholder="Select an option"
value={value}
onValueChange={setValue}
size={args.size}
disabled={args.disabled}
>
<Select.Option value="1">Option 1</Select.Option>
<Select.Option value="2">Option 2</Select.Option>
</Select.Root>
);
},
};

export const MultiSelect: Story = {
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [value, setValue] = React.useState<Array<string>>([]);
return (
<Select.Root
placeholder="Select an option"
value={value}
onValueChange={setValue}
size={args.size}
multiple
disabled={args.disabled}
>
<Select.Option value="1">Option 1</Select.Option>
<Select.Option value="2">Option 2</Select.Option>
<Select.Option value="3">Option 3</Select.Option>
<Select.Option value="4">Option 4</Select.Option>
</Select.Root>
);
},
};
Loading

0 comments on commit 57d486e

Please sign in to comment.