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

Merged
merged 20 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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
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);
}
}
}
}
114 changes: 114 additions & 0 deletions packages/components/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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);
controller.open();
Lisa18289 marked this conversation as resolved.
Show resolved Hide resolved
};

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

const controller = controllerFromProps ?? controllerFromContext;

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;
5 changes: 5 additions & 0 deletions packages/components/src/components/ComboBox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ComboBox } from "./ComboBox";

export { type ComboBoxProps, ComboBox } from "./ComboBox";
export * from "../Option";
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": "Show options"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { Meta, StoryObj } from "@storybook/react";
import React, { useState } 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";
import { Section } from "@/components/Section";
import { ColumnLayout } from "@/components/ColumnLayout";
import { TextField } from "@/components/TextField";
import { Select } from "@/components/Select";

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>
),
};

export const Emails: Story = {
render: (props) => {
const [value, setValue] = useState<string>();

const domains = ["a.de", "b.de", "c.de"];

const options = value?.includes("@")
? domains.map((d) => (
<Option key={d} value={value?.split("@")[0] + "@" + d}>
{value?.split("@")[0] + "@" + d}
</Option>
))
: null;

console.log(options);
Lisa18289 marked this conversation as resolved.
Show resolved Hide resolved

return (
<Section>
<ComboBox {...props} isRequired onInputChange={(v) => setValue(v)}>
<Label>Domain</Label>
{options}
</ComboBox>
<ColumnLayout>
<TextField>
<Label>Name</Label>
</TextField>
<Select defaultSelectedKey="@a.de">
<Label>Domain</Label>
<Option value="@a.de">@a.de</Option>
<Option value="@b.de">@b.de</Option>
<Option value="@c.de">@c.de</Option>
</Select>
</ColumnLayout>
</Section>
);
},
};

export const Files: Story = {
render: (props) => {
const files1 = ["home", "var"];
const files2 = ["home/www", "home/backup", "home/etc"];
const files3 = ["var/foo", "var/bar"];
const [files, setFiles] = useState<string[]>(files1);

const options = files.map((f) => (
<Option key={f} value={f}>
{f}
</Option>
));

return (
<ComboBox
{...props}
isRequired
onSelectionChange={(v) => {
if (v === "home") {
setFiles(files2);
}
if (v === "var") {
setFiles(files3);
}
}}
>
<Label>Domain</Label>
{options}
</ComboBox>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ 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");

return (
<Popover className={styles.popover} controller={controller}>
<Aria.ListBox className={rootClassName} {...rest}>
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 @@ -9,7 +9,7 @@
border-width: var(--popover--border-width);
border-style: var(--popover--border-style);
border-color: var(--popover--border-color);
max-width: 90vw;
max-width: 100dvw;

:where(.content) {
overflow-y: auto;
Expand Down
Loading
Loading