Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RESET='\033[0m'
DIR="$PWD"

# The required yarn version to use this script
REQUIRED_YARN_VERSION="1.22.19"
REQUIRED_YARN_VERSION="1.22.22"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this added by mistake?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't able to run locally without this set to that version. I wasn't sure if I should upload this here for future or if I just keep it locally.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running storybook? Are you running yarn dev?

Copy link
Copy Markdown
Contributor Author

@jules-exel jules-exel Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep I do yarn run dev

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm interesting, let's merge this in and see what happens.


# Get the installed Yarn version
INSTALLED_YARN_VERSION=$(yarn --version 2>/dev/null)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agility/plenum-ui",
"version": "2.2.9",
"version": "2.3.0",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down
52 changes: 49 additions & 3 deletions stories/molecules/inputs/select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,58 @@ const meta: Meta<typeof Select> = {
(Story, context) => {
if (context.name === "Default Select Dark BG") {
return (
<div className="bg-transparent-black-03 rounded p-6">
<div className="bg-transparent-black-03 rounded p-6 w-60">
<Story />
</div>
);
}
return <Story />;
return (
<div className="w-64">
<Story />
</div>
);
}
]
};

export default meta;
type TStory = StoryObj<typeof Select>;

const manyCountries = [
{
label: "Australia",
value: "au",
description: "A country and continent"
},
{ label: "Brazil", value: "br" },
{ label: "Canada", value: "ca" },
{ label: "China", value: "cn" },
{ label: "Denmark", value: "dk" },
{ label: "Egypt", value: "eg" },
{ label: "France", value: "fr" },
{ label: "Germany", value: "de" },
{ label: "India", value: "in" },
{ label: "Italy", value: "it" },
{ label: "Japan", value: "jp" },
{ label: "Mexico", value: "mx" },
{ label: "Netherlands", value: "nl" },
{ label: "New Zealand", value: "nz" },
{ label: "Norway", value: "no" },
{ label: "Portugal", value: "pt" },
{ label: "South Korea", value: "kr" },
{ label: "Spain", value: "es" },
{ label: "Sweden", value: "se" },
{ label: "United Kingdom", value: "gb" },
{ label: "United States", value: "us" }
];

export const DefaultSelect: TStory = {
args: {
label: "Label",
id: "select",
name: "select",
options: [
{ label: "Canada", value: "value1" },
{ label: "Canada", value: "value1", description: "A description for Canada." },
{ label: "USA", value: "value2" }
],
isDisabled: false,
Expand All @@ -38,6 +70,20 @@ export const DefaultSelect: TStory = {
message: "Message"
}
};

export const ManyOptions: TStory = {
args: {
label: "Country",
id: "select-many",
name: "select-many",
options: manyCountries,
isDisabled: false,
isError: false,
isRequired: false,
message: "Scroll to see all options"
}
};

export const DefaultSelectDarkBG: TStory = {
args: {
label: "Label",
Expand Down
170 changes: 128 additions & 42 deletions stories/molecules/inputs/select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import InputLabel from "@/stories/molecules/inputs/InputLabel";
import { DynamicIcon } from "@/stories/atoms/icons/DynamicIcon";
import { useId } from "@/utils/useId";
import { default as cn } from "classnames";
import Paragraph from "@/stories/atoms/Typography/Paragraph/Paragraph";
import {
Combobox as HeadlessCombobox,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption
} from "@headlessui/react";
import { Paragraph } from "@/stories/atoms/Typography/Paragraph";

export interface ISimpleSelectOptions {
label: string;
value: string;
emoji?: string;
description?: string;
}

export interface ISelectProps {
/** Label */
label?: string;
Expand All @@ -17,7 +28,7 @@ export interface ISelectProps {
name?: string;
/** List of options to display in the select menu */
options: ISimpleSelectOptions[];
/** Select name prop */
/** Called with the selected option's value string */
onChange?(value: string): void;
/** Select disabled state */
isDisabled?: boolean;
Expand All @@ -30,7 +41,11 @@ export interface ISelectProps {
onFocus?: () => void;
onBlur?: () => void;
message?: string;
inputRef?: React.RefObject<HTMLInputElement>;
placeholder?: string;
dropdownMaxHeight?: number;
}

const Select: React.FC<ISelectProps> = ({
label,
id,
Expand All @@ -44,57 +59,128 @@ const Select: React.FC<ISelectProps> = ({
className,
onFocus,
onBlur,
message
message,
inputRef,
placeholder = "Select",
dropdownMaxHeight = 240
}) => {
const [selectedOption, setSelectedOption] = useState<string>(value || options[0].value);
const uniqueID = useId();
if (!id) id = `select-${uniqueID}`;
if (!name) name = id;

const findOption = (val?: string) => options.find((o) => o.value === val) ?? null;

const [selectedOption, setSelectedOption] = useState<ISimpleSelectOptions | null>(findOption(value));

useEffect(() => {
if (value !== undefined && value !== null) {
setSelectedOption(value);
}
setSelectedOption(findOption(value));
}, [value]);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const targetValue = e.target.value;
typeof onChange == "function" && onChange(targetValue);
setSelectedOption(targetValue);
const handleChange = (option: ISimpleSelectOptions | null) => {
setSelectedOption(option);
if (option && typeof onChange === "function") {
onChange(option.value);
}
};
const wrapperStyle = cn("group", { "opacity-50": isDisabled });

const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number | undefined>();

useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
observer.observe(el);
return () => observer.disconnect();
}, []);

const wrapperStyle = cn(className, "w-full", "group", { "opacity-50 pointer-events-none": isDisabled });

return (
<div className={wrapperStyle}>
{label && <InputLabel isActive label={label} isRequired={isRequired} id={id} isDisabled={isDisabled} />}
<select
id={id}
name={name}
className={cn(
"block w-full border-gray-300 py-2 pl-3 pr-10 text-base focus:outline-none",
"rounded focus:border-purple-500 focus:ring-purple-500 sm:text-sm",
{ "border-red-500": isError },
{ "border-gray-300": !isError },
className
{label && <InputLabel id={`${id}-label`} label={label} isRequired={isRequired} />}

<HeadlessCombobox value={selectedOption} onChange={handleChange} disabled={isDisabled} immediate by="value">
<div ref={containerRef} className="relative w-full">
<div
className={cn(
"relative w-full cursor-default overflow-hidden rounded border bg-white text-left shadow-sm",
"focus-within:border-primary-800 focus-within:ring-1 focus-within:ring-primary-800",
{ "border-red-500": isError, "border-gray-300": !isError }
)}
>
<ComboboxInput
id={id}
name={name}
ref={inputRef}
readOnly
displayValue={(option: ISimpleSelectOptions | null) => (option ? option.label : "")}
placeholder={placeholder}
onFocus={onFocus}
onBlur={onBlur}
className={cn(
"w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-700",
"placeholder:text-gray-400",
"focus:outline-none focus:ring-0",
"bg-transparent cursor-default"
)}
/>

<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-3">
{({ open }) => (
<DynamicIcon
icon="IconChevronDown"
className={cn("h-4 w-4 text-gray-400 transition-transform", { "rotate-180": open })}
aria-hidden="true"
/>
)}
</ComboboxButton>
</div>

<ComboboxOptions
anchor="bottom start"
style={
{
"--anchor-max-height": `${dropdownMaxHeight}px`,
minWidth: containerWidth
} as React.CSSProperties
}
className={cn(
"z-[9999] overflow-auto rounded bg-white py-1",
"text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
"[--anchor-gap:8px]"
)}
>
{options.map((option) => (
<ComboboxOption
key={option.value}
value={option}
className={({ focus }) =>
cn(
"relative cursor-default select-none mx-xxsm rounded",
focus ? "bg-gray-100 text-gray-900" : "text-gray-700"
)
}
>
{({ selected }) => (
<div className="py-xxsm px-sm flex items-center gap-xsm">
<Paragraph size="md">{option.label}</Paragraph>
{option.description ? (
<Paragraph size="md" className="text-neutral-500">{option.description}</Paragraph>
) : null}
</div>
)}
</ComboboxOption>
))}
</ComboboxOptions>
</div>

{message && (
<Paragraph size="md" className={isError ? "text-red-600" : "text-gray-500 pt-xxsm"}>
{message}
</Paragraph>
)}
onChange={handleChange}
disabled={isDisabled}
value={selectedOption}
onFocus={onFocus}
onBlur={onBlur}
>
{options.map(({ value, label }) => {
return (
<option key={value} value={value}>
{label}
</option>
);
})}
</select>
{message && (
<Paragraph size="md" className={isError ? "text-red-600" : "text-gray-500"}>
{message}
</Paragraph>
)}
</HeadlessCombobox>
</div>
);
};
Expand Down
Loading
Loading