Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ComboBox component #461

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
10 changes: 10 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@
"types": "./dist/js/types/components/ColumnLayout/index.d.ts",
"import": "./dist/js/ColumnLayout.js"
},
"./ComboBox/styles.css": "./dist/css/ComboBox.css",
"./ComboBox": {
"types": "./dist/js/types/components/ComboBox/index.d.ts",
"import": "./dist/js/ComboBox.js"
},
"./Content": {
"types": "./dist/js/types/components/Content/index.d.ts",
"import": "./dist/js/Content.js"
Expand Down Expand Up @@ -238,6 +243,11 @@
"types": "./dist/js/types/components/NumberField/index.d.ts",
"import": "./dist/js/NumberField.js"
},
"./Option/styles.css": "./dist/css/Option.css",
"./Option": {
"types": "./dist/js/types/components/Option/index.d.ts",
"import": "./dist/js/Option.js"
},
"./Popover/styles.css": "./dist/css/Popover.css",
"./Popover": {
"types": "./dist/js/types/components/Popover/index.d.ts",
Expand Down
35 changes: 35 additions & 0 deletions packages/components/src/components/ComboBox/ComboBox.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@use "@/styles/mixins/formControl.scss";

.comboBox {
.input {
order: 2;
display: grid;
grid-template-areas: "input";

input {
@include formControl.formControl();
grid-area: input;
}

.toggle {
grid-area: input;
justify-self: end;
margin: var(--form-control--border-width);

&:hover {
background-color: transparent;
}

&[data-pressed] {
background-color: transparent;
}
}

&:hover {
input,
.toggle {
background-color: var(--form-control--background-color--hover);
}
}
}
}
117 changes: 117 additions & 0 deletions packages/components/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { PropsWithChildren } from "react";
import React from "react";
import type { Key } from "react-aria-components";
import * as Aria from "react-aria-components";
import { TunnelExit, TunnelProvider } from "@mittwald/react-tunnel";
import { Button } from "@/components/Button";
import { IconChevronDown } from "@/components/Icon/components/icons";
import { Options } from "@/components/Options";
import type { PropsContext } from "@/lib/propsContext";
import { PropsContextProvider } from "@/lib/propsContext";
import clsx from "clsx";
import styles from "./ComboBox.module.scss";
import formFieldStyles from "@/components/FormField/FormField.module.scss";
import locales from "./locales/*.locale.json";
import { useLocalizedStringFormatter } from "react-aria";
import type { FlowComponentProps } from "@/lib/componentFactory/flowComponent";
import { flowComponent } from "@/lib/componentFactory/flowComponent";
import { type OverlayController, useOverlayController } from "@/lib/controller";

export interface ComboBoxProps
extends Omit<Aria.ComboBoxProps<never>, "children">,
PropsWithChildren,
FlowComponentProps {
onChange?: (value: string) => void;
controller?: OverlayController;
}

export const ComboBox = flowComponent("ComboBox", (props) => {
const {
children,
className,
menuTrigger = "focus",
onChange = () => {
// default: do nothing
},
onSelectionChange = () => {
// default: do nothing
},
controller: controllerFromProps,
refProp: ref,
...rest
} = props;

const stringFormatter = useLocalizedStringFormatter(locales);

const rootClassName = clsx(
styles.comboBox,
formFieldStyles.formField,
className,
);

const propsContext: PropsContext = {
Label: {
className: formFieldStyles.label,
optional: !props.isRequired,
},
FieldDescription: {
className: formFieldStyles.fieldDescription,
},
FieldError: {
className: formFieldStyles.customFieldError,
},
Option: {
tunnelId: "options",
},
};

const handleOnSelectionChange = (key: Key | null) => {
onChange(String(key));
onSelectionChange(key);
};

const controllerFromContext = useOverlayController("ComboBox", {
reuseControllerFromContext: true,
});

const controller = controllerFromProps ?? controllerFromContext;

const isOpen = controller.useIsOpen();

console.log(isOpen);

return (
<Aria.ComboBox
menuTrigger={menuTrigger}
className={rootClassName}
{...rest}
ref={ref}
onSelectionChange={handleOnSelectionChange}
onOpenChange={(isOpen) => controller.setOpen(isOpen)}
>
<PropsContextProvider props={propsContext}>
<TunnelProvider>
<div className={styles.input}>
<Aria.Input />
<Button
className={styles.toggle}
aria-label={stringFormatter.format("comboBox.showOptions")}
variant="plain"
color="secondary"
>
<IconChevronDown />
</Button>
</div>

{children}

<Options controller={controller}>
<TunnelExit id="options" />
</Options>
</TunnelProvider>
</PropsContextProvider>
</Aria.ComboBox>
);
});

export default ComboBox;
4 changes: 4 additions & 0 deletions packages/components/src/components/ComboBox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ComboBox } from "./ComboBox";

export { type ComboBoxProps, ComboBox } from "./ComboBox";
export default ComboBox;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"comboBox.showOptions": "Auswahlmöglichkeiten anzeigen"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"comboBox.showOptions": "Auswahlmöglichkeiten anzeigen"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Label } from "@/components/Label";
import { Option } from "@/components/Option";
import FieldDescription from "@/components/FieldDescription";
import { FieldError } from "@/components/FieldError";
import { ComboBox } from "@/components/ComboBox";

const meta: Meta<typeof ComboBox> = {
title: "Form Controls/ComboBox",
component: ComboBox,
render: (props) => (
<ComboBox {...props}>
<Label>Domain</Label>
<Option>mydomain.de</Option>
<Option>shop.mydomain.de</Option>
<Option>anotherdomain.com</Option>
<Option>www.anotherdomain.com</Option>
<Option>anotherdomain.com/shop</Option>
<Option>anotherdomain.com/blog</Option>
<Option>onemoredomain.de</Option>
<Option>www.onemoredomain.de</Option>
</ComboBox>
),
};
export default meta;

type Story = StoryObj<typeof ComboBox>;

export const Default: Story = {};

export const Disabled: Story = { args: { isDisabled: true } };

export const Required: Story = {
args: { isRequired: true },
};

export const WithFieldDescription: Story = {
render: (props) => (
<ComboBox {...props}>
<Label>Domain</Label>
<Option>mydomain.de</Option>
<Option>shop.mydomain.de</Option>
<Option>anotherdomain.com</Option>
<Option>www.anotherdomain.com</Option>
<Option>anotherdomain.com/shop</Option>
<Option>anotherdomain.com/blog</Option>
<Option>onemoredomain.de</Option>
<Option>www.onemoredomain.de</Option>
<FieldDescription>Select a domain</FieldDescription>
</ComboBox>
),
};

export const WithDefaultValue: Story = {
render: (props) => (
<ComboBox {...props} defaultSelectedKey="mydomain.de">
<Label>Domain</Label>
<Option value="mydomain.de">mydomain.de</Option>
<Option>shop.mydomain.de</Option>
<Option>anotherdomain.com</Option>
<Option>www.anotherdomain.com</Option>
<Option>anotherdomain.com/shop</Option>
<Option>anotherdomain.com/blog</Option>
<Option>onemoredomain.de</Option>
<Option>www.onemoredomain.de</Option>
</ComboBox>
),
};

export const WithFieldError: Story = {
render: (props) => (
<ComboBox {...props} isInvalid isRequired>
<Label>Domain</Label>
<Option>mydomain.de</Option>
<Option>shop.mydomain.de</Option>
<Option>anotherdomain.com</Option>
<Option>www.anotherdomain.com</Option>
<Option>anotherdomain.com/shop</Option>
<Option>anotherdomain.com/blog</Option>
<Option>onemoredomain.de</Option>
<Option>www.onemoredomain.de</Option>
<FieldError>Select a domain to continue</FieldError>
</ComboBox>
),
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ import React from "react";
import * as Aria from "react-aria-components";
import { Popover } from "@/components/Popover";
import clsx from "clsx";
import type { OptionProps } from "@/components/Select";
import styles from "./Options.module.scss";
import { useOverlayController } from "@/lib/controller";
import type { OverlayController } from "@/lib/controller";
import type { OptionProps } from "@/components/Option";

export type OptionsProps = Aria.ListBoxProps<OptionProps>;
export interface OptionsProps extends Aria.ListBoxProps<OptionProps> {
controller: OverlayController;
}

export const Options: FC<OptionsProps> = (props) => {
const { className, children, ...rest } = props;
const { className, children, controller, ...rest } = props;

const rootClassName = clsx(styles.options, className);

const controller = useOverlayController("Select");
const isOpen = controller.useIsOpen();

return (
<Popover className={styles.popover} controller={controller}>
<Popover className={styles.popover} isOpen={isOpen} controller={controller}>
<Aria.ListBox className={rootClassName} {...rest}>
{children}
</Aria.ListBox>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Options } from "./Options";

export { Options } from "./Options";
export default Options;
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
text-align: left;

@include formControl.formControl();
padding-inline-end: var(--button--padding);
}

&[data-invalid] {
Expand Down
31 changes: 14 additions & 17 deletions packages/components/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import clsx from "clsx";
import { IconChevronDown } from "@/components/Icon/components/icons";
import type { FlowComponentProps } from "@/lib/componentFactory/flowComponent";
import { flowComponent } from "@/lib/componentFactory/flowComponent";
import { Options } from "@/components/Select/components/Options";
import { Options } from "@/components/Options";
import { TunnelExit, TunnelProvider } from "@mittwald/react-tunnel";
import type { PropsWithClassName } from "@/lib/types/props";
import { type OverlayController, useOverlayController } from "@/lib/controller";
import OverlayContextProvider from "@/lib/controller/overlay/OverlayContextProvider";

export interface SelectProps
extends PropsWithChildren<
Expand Down Expand Up @@ -86,23 +85,21 @@ export const Select = flowComponent("Select", (props) => {
onOpenChange={(isOpen) => controller.setOpen(isOpen)}
isOpen={isOpen}
>
<OverlayContextProvider type="Select" controller={controller}>
<PropsContextProvider props={propsContext}>
<TunnelProvider>
<Aria.Button className={styles.toggle}>
<Aria.SelectValue />
<IconChevronDown />
</Aria.Button>
<PropsContextProvider props={propsContext}>
<TunnelProvider>
<Aria.Button className={styles.toggle}>
<Aria.SelectValue />
<IconChevronDown />
</Aria.Button>

{children}
<Options>
<TunnelExit id="options" />
</Options>
{children}
<Options controller={controller}>
<TunnelExit id="options" />
</Options>

<FieldError className={formFieldStyles.fieldError} />
</TunnelProvider>
</PropsContextProvider>
</OverlayContextProvider>
<FieldError className={formFieldStyles.fieldError} />
</TunnelProvider>
</PropsContextProvider>
</Aria.Select>
);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/components/Select/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Select } from "./Select";

export { type SelectProps, Select } from "./Select";
export * from "./components/Option";
export default Select;
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react";
import Select, { Option } from "../index";
import Select from "../index";
import React from "react";
import { Label } from "@/components/Label";
import { Option } from "@/components/Option";
import FieldDescription from "@/components/FieldDescription";
import { FieldError } from "@/components/FieldError";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react";
import Select, { Option } from "../index";
import Select from "../index";
import React from "react";
import { Label } from "@/components/Label";
import { Option } from "@/components/Option";
import defaultMeta from "./Default.stories";

const meta: Meta<typeof Select> = {
Expand Down
Loading