Skip to content
Open
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/components/base/src/boolean-input/BooleaInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef } from "react";
import classNames from "classnames";
import { type FormFieldContextProps, useFormFieldContext } from "../form-field";
import { type BooleanInputIconComponent } from "./types";
import { megeRefs } from "@react-ck/react-utils";
import { mergeRefs } from "@react-ck/react-utils";

/**
* Props interface for the BooleanInput component.
Expand Down Expand Up @@ -58,7 +58,7 @@ export const BooleaInput = ({
(disabled || formFieldContext?.disabled) && styles.disabled,
)}>
<input
ref={megeRefs(ref, inputRef)}
ref={mergeRefs(ref, inputRef)}
id={computedId}
className={styles.input}
disabled={disabled}
Expand Down
4 changes: 2 additions & 2 deletions packages/components/base/src/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ScrollableContainer } from "../scrollable-container";
import { Card } from "../card";
import styles from "./index.module.scss";
import classNames from "classnames";
import { megeRefs, useOnClickOutside } from "@react-ck/react-utils";
import { mergeRefs, useOnClickOutside } from "@react-ck/react-utils";
import { FocusTrap } from "@react-ck/focus-trap";
import { KeyboardControls } from "@react-ck/keyboard-controls";

Expand Down Expand Up @@ -141,7 +141,7 @@ export const Dropdown = ({
<Layer elevation="overlay" group="dropdown">
<div
ref={(el) => {
megeRefs(ref, containerRef)(el);
mergeRefs(ref, containerRef)(el);
setFocusWrapperElement(el || undefined);
}}
tabIndex={0}
Expand Down
4 changes: 2 additions & 2 deletions packages/components/base/src/file-uploader/FileUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import classNames from "classnames";
import { Text } from "../text";
import { Button, type ButtonProps } from "../button";
import { readFileList } from "./utils/read-file";
import { megeRefs } from "@react-ck/react-utils";
import { mergeRefs } from "@react-ck/react-utils";

// TODO: check https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file
// TODO: add size limitation: https://stackoverflow.com/questions/5697605/limit-the-size-of-a-file-upload-html-input-element
Expand Down Expand Up @@ -106,7 +106,7 @@ export const FileUploader = ({
)}>
<input
{...inputProps}
ref={megeRefs(inputRef, inputProps?.ref)}
ref={mergeRefs(inputRef, inputProps?.ref)}
type="file"
className={classNames(styles.file, inputProps?.className)}
onChange={handleChange}
Expand Down
51 changes: 51 additions & 0 deletions packages/components/base/src/select/SelectGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from "react";
import { DISPLAY_NAME_ATTRIBUTE } from "@react-ck/react-utils";
import { type SelectGroupProps } from "./types";
import { Text } from "../text";
import styles from "./styles/index.module.scss";
import { useManagerContext } from "@react-ck/manager";

/**
* A group component for organizing Select options with a label.
* Provides visual separation and organization for related options.
*
* @example
* ```tsx
* <Select>
* <Select.Group name="Fruits">
* <Select.Option value="apple">Apple</Select.Option>
* <Select.Option value="banana">Banana</Select.Option>
* </Select.Group>
* <Select.Group name="Vegetables">
* <Select.Option value="carrot">Carrot</Select.Option>
* <Select.Option value="broccoli">Broccoli</Select.Option>
* </Select.Group>
* </Select>
* ```
*
* @param props - Component props {@link SelectGroupProps}
* @returns React element
*/
const SelectGroup = ({
name,
children,
}: Readonly<Omit<SelectGroupProps, "disabled">>): React.ReactElement => {
const { generateUniqueId } = useManagerContext();

const uniqueId = generateUniqueId();

return (
<div role="group" aria-labelledby={uniqueId} className={styles.group}>
<div id={uniqueId} className={styles.group_name}>
<Text margin="none" variation="small" skin={["bold", "soft"]}>
{name}
</Text>
</div>
{children}
</div>
);
};

SelectGroup[DISPLAY_NAME_ATTRIBUTE] = "SelectGroup";

export { SelectGroup };
81 changes: 63 additions & 18 deletions packages/components/base/src/select/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import styles from "./styles/index.module.scss";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SelectOption } from "./SelectOption";
import { SelectGroup } from "./SelectGroup";
import { Menu } from "../menu";
import { Dropdown } from "../dropdown";
import { Input } from "../input";
import classNames from "classnames";
import { megeRefs, raf } from "@react-ck/react-utils";
import { mergeRefs, raf } from "@react-ck/react-utils";
import { EmptyState } from "../empty-state";
import { getChildrenData, simplifyString, valueAsArray } from "./utils";
import { type SelectProps, type ChangeHandler } from "./types";
import { SelectContext, type SelectContextProps } from "./context";
import { useFormFieldContext } from "../form-field";
import { Spinner } from "../spinner";
import { Icon } from "@react-ck/icon";
import { IconChevronDown } from "@react-ck/icon/icons/IconChevronDown";

/** Default positions to exclude from auto-positioning */
const defaultExclude: SelectProps["excludeAutoPosition"] = [
Expand Down Expand Up @@ -75,6 +79,7 @@ const Select = ({
allowDeselect = true,
required,
disabled,
loading,
displayValueDivider = ",",
fullWidth,
position,
Expand Down Expand Up @@ -105,17 +110,47 @@ const Select = ({
const childrenData = useMemo(() => getChildrenData(children), [children]);

/** Options list children filtered by user search */
const filteredOptions = useMemo(
() =>
childrenData
.filter(
(i) =>
!search.length ||
(i.textContent && simplifyString(i.textContent).includes(simplifyString(search))),
)
.map((i) => i.element),
[childrenData, search],
);
const filteredOptions = useMemo(() => {
const filtered = childrenData.filter(
(i) =>
!search.length ||
(i.textContent && simplifyString(i.textContent).includes(simplifyString(search))),
);

// Group options by groupName
const groupedOptions: { [key: string]: React.ReactNode[] } = {};
const ungroupedOptions: React.ReactNode[] = [];

filtered.forEach((item) => {
if (item.isSelectOption) {
if (item.groupName) {
if (!(item.groupName in groupedOptions)) {
groupedOptions[item.groupName] = [];
}
groupedOptions[item.groupName]?.push(item.element);
} else {
ungroupedOptions.push(item.element);
}
}
});

// Render grouped options
const result: React.ReactNode[] = [];

// Add ungrouped options first
ungroupedOptions.forEach((option) => result.push(option));

// Add grouped options using SelectGroup component
Object.entries(groupedOptions).forEach(([groupName, options]) => {
result.push(
<SelectGroup key={groupName} name={groupName}>
{options}
</SelectGroup>,
);
});

Comment on lines +121 to +151
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve SelectGroup props when rebuilding grouped options

We rebuild each group but only pass name, dropping every other prop coming from the original <Select.Group> (e.g. description, disabled, className, test ids). Any consumer relying on those props loses functionality as soon as a search term is applied. Please carry the captured selectGroupProps forward when rendering the grouped result so groups behave identically before and after filtering.

Suggested approach:

-    const groupedOptions: { [key: string]: React.ReactNode[] } = {};
+    const groupedOptions: {
+      [key: string]: { options: React.ReactNode[]; props: SelectGroupProps };
+    } = {};
@@
-          if (!(item.groupName in groupedOptions)) {
-            groupedOptions[item.groupName] = [];
-          }
-          groupedOptions[item.groupName]?.push(item.element);
+          if (!(item.groupName in groupedOptions)) {
+            groupedOptions[item.groupName] = {
+              options: [],
+              props: item.selectGroupProps ?? { name: item.groupName },
+            };
+          }
+          groupedOptions[item.groupName]?.options.push(item.element);
@@
-    Object.entries(groupedOptions).forEach(([groupName, options]) => {
+    Object.entries(groupedOptions).forEach(([groupName, { options, props }]) => {
       result.push(
-        <SelectGroup key={groupName} name={groupName}>
+        <SelectGroup key={groupName} {...props}>
           {options}
         </SelectGroup>,
       );
     });

Make sure to import SelectGroupProps (or reuse the existing type) accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const groupedOptions: { [key: string]: React.ReactNode[] } = {};
const ungroupedOptions: React.ReactNode[] = [];
filtered.forEach((item) => {
if (item.isSelectOption) {
if (item.groupName) {
if (!(item.groupName in groupedOptions)) {
groupedOptions[item.groupName] = [];
}
groupedOptions[item.groupName]?.push(item.element);
} else {
ungroupedOptions.push(item.element);
}
}
});
// Render grouped options
const result: React.ReactNode[] = [];
// Add ungrouped options first
ungroupedOptions.forEach((option) => result.push(option));
// Add grouped options using SelectGroup component
Object.entries(groupedOptions).forEach(([groupName, options]) => {
result.push(
<SelectGroup key={groupName} name={groupName}>
{options}
</SelectGroup>,
);
});
const groupedOptions: {
[key: string]: { options: React.ReactNode[]; props: SelectGroupProps };
} = {};
const ungroupedOptions: React.ReactNode[] = [];
filtered.forEach((item) => {
if (item.isSelectOption) {
if (item.groupName) {
if (!(item.groupName in groupedOptions)) {
groupedOptions[item.groupName] = {
options: [],
props: item.selectGroupProps ?? { name: item.groupName },
};
}
groupedOptions[item.groupName]?.options.push(item.element);
} else {
ungroupedOptions.push(item.element);
}
}
});
// Render grouped options
const result: React.ReactNode[] = [];
// Add ungrouped options first
ungroupedOptions.forEach((option) => result.push(option));
// Add grouped options using SelectGroup component
Object.entries(groupedOptions).forEach(([groupName, { options, props }]) => {
result.push(
<SelectGroup key={groupName} {...props}>
{options}
</SelectGroup>,
);
});
🤖 Prompt for AI Agents
In packages/components/base/src/select/index.tsx around lines 121 to 151, the
code rebuilds grouped options but only passes the group `name` into the
recreated <SelectGroup>, dropping other props (description, disabled, className,
test ids, etc.). Modify the grouping logic to capture the original SelectGroup
props (use or import SelectGroupProps) alongside each group's elements when
building groupedOptions, store those props keyed by groupName, and when
rendering call <SelectGroup key={groupName} name={groupName}
{...selectGroupProps[groupName]}>{options}</SelectGroup> so all original group
props are preserved; ensure types reflect SelectGroupProps and maintain unique
keys as before.

return result;
}, [childrenData, search]);

/** Returns the internal value always as an array to facilitate operations */
const selectedValuesList = useMemo(
Expand Down Expand Up @@ -256,7 +291,9 @@ const Select = ({
const resizeObserver = new ResizeObserver(() => {
if (!valueSlotRefCurrent || !sizeSetterRef.current) return;

valueSlotRefCurrent.style.width = `${sizeSetterRef.current.clientWidth + 10}px`;
if (valueSlotRefCurrent.clientWidth < sizeSetterRef.current.clientWidth) {
valueSlotRefCurrent.style.width = `${sizeSetterRef.current.clientWidth + 10}px`;
}
});
resizeObserver.observe(sizeSetterRef.current);

Expand All @@ -278,7 +315,7 @@ const Select = ({
styles[`skin_${computedSkin}`],
formFieldContext === undefined && styles.standalone,
(disabled || formFieldContext?.disabled) && styles.disabled,

loading && styles.loading,
(fullWidth ?? formFieldContext?.fullWidth) && styles.full_width,
className,
)}
Expand All @@ -291,11 +328,17 @@ const Select = ({
onBlur?.(e);
}}>
<div ref={valueSlotRef} className={styles.value_slot}>
{displayValue || <span className={styles.placeholder}>{placeholder}</span>}
<div className={styles.value_content}>
{displayValue || <span className={styles.placeholder}>{placeholder}</span>}
</div>
{loading && <Spinner size="l" />}
<Icon size="l">
<IconChevronDown />
</Icon>
</div>

<select
ref={megeRefs(ref, selectRef)}
ref={mergeRefs(ref, selectRef)}
id={computedId}
name={selectName}
multiple={selectMultiple}
Expand Down Expand Up @@ -372,15 +415,17 @@ const Select = ({

type SelectWithOption = typeof Select & {
Option: typeof SelectOption;
Group: typeof SelectGroup;
};

// Add the Option property
// Add the Option and Group properties

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- needed for compound component definition with forward ref
const CompoundSelect: SelectWithOption = Select as SelectWithOption;

CompoundSelect.Option = SelectOption;
CompoundSelect.Group = SelectGroup;

export { CompoundSelect as Select };

export { type SelectOptionProps, type SelectProps } from "./types";
export { type SelectOptionProps, type SelectProps, type SelectGroupProps } from "./types";
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,31 @@ exports[`snapshot Select renders correctly 1`] = `
<div
class="value_slot"
>
<div
class="value_content"
>
<span
class="placeholder"
/>
</div>
<span
class="placeholder"
/>
class="root size_l skin_default"
>
<svg
fill="none"
height="1em"
stroke="currentColor"
stroke-width="0"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.34317 7.75732L4.92896 9.17154L12 16.2426L19.0711 9.17157L17.6569 7.75735L12 13.4142L6.34317 7.75732Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<select
class="native_element"
Expand Down
43 changes: 34 additions & 9 deletions packages/components/base/src/select/styles/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
.root {
@include form-field.form-field-input;

position: relative;
display: inline-block;
vertical-align: middle;
cursor: pointer;
position: relative;
min-width: theme.get-spacing(10);
cursor: pointer;

&.full_width {
@include form-field.form-field-input-full-width;
Expand All @@ -18,21 +18,28 @@
}

.value_slot {
display: flex;
max-width: 100%;
align-items: center;
justify-content: space-between;
gap: theme.get-spacing(1);
}

.value_content {
display: flex;
align-items: flex-start;
justify-self: unset;
flex-wrap: wrap;
gap: theme.get-spacing(0.5);
max-width: 100%;
}

.display_value_item {
display: inline-block;
vertical-align: middle;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
max-width: 100%;
vertical-align: middle;
}

// When not inside form field, has own border, etc
Expand Down Expand Up @@ -64,12 +71,16 @@

.native_element {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 1;
pointer-events: none;
}

.loading {
pointer-events: none;
}

Expand All @@ -82,9 +93,23 @@
It is used to ensure that the value slot is the same width as the native select element.
*/
.size_setter {
position: absolute;
display: block;
flex-direction: column;
pointer-events: none;
position: absolute;
opacity: 0;
pointer-events: none;
}

.group {
padding-top: theme.get-spacing(1);

&:not(:first-of-type) {
border-top: 1px solid theme.get-color(neutral-light-300);
margin-top: theme.get-spacing(1);
}
}

.group_name {
margin-inline: theme.get-spacing(1);
margin-bottom: theme.get-spacing(0.5);
}
Loading