Skip to content

Commit 44c6a33

Browse files
committed
feat(combobox): implement component
1 parent 89cd874 commit 44c6a33

30 files changed

+1669
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { StorybookConfig } from '@storybook/react-vite';
2+
3+
const config: StorybookConfig = {
4+
core: {
5+
disableTelemetry: true,
6+
disableWhatsNewNotifications: true,
7+
},
8+
docs: {
9+
autodocs: false,
10+
},
11+
framework: '@storybook/react-vite',
12+
previewHead: (head) => `
13+
${head}
14+
<style>
15+
html, body {
16+
font-family: "Source Sans Pro", "Trebuchet MS", "Arial", "Segoe UI", sans-serif;
17+
}
18+
</style>
19+
`,
20+
stories: [
21+
'../src/dev.stories.tsx',
22+
'../tests/**/*.stories.tsx',
23+
],
24+
};
25+
26+
export default config;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { addons } from '@storybook/manager-api';
2+
3+
addons.register('custom-panel', (api) => {
4+
api.togglePanel(false);
5+
});
6+
7+
addons.setConfig({
8+
enableShortcuts: false,
9+
showToolbar: true,
10+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { type Preview } from '@storybook/react';
2+
import '@ovhcloud/ods-themes/default';
3+
4+
const preview: Preview = {
5+
parameters: {},
6+
};
7+
8+
export default preview;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const isCI = !!process.env.CI;
2+
3+
export default {
4+
launch: {
5+
headless: isCI,
6+
slowMo: isCI ? 0 : 300,
7+
product: 'chrome',
8+
args: [
9+
'--no-sandbox',
10+
'--disable-setuid-sandbox',
11+
"--disable-dev-shm-usage",
12+
"--disable-accelerated-2d-canvas",
13+
"--disable-gpu",
14+
'--font-render-hinting=none',
15+
],
16+
},
17+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const baseOption = {
2+
collectCoverage: false,
3+
testPathIgnorePatterns: [
4+
'node_modules/',
5+
'dist/',
6+
],
7+
testRegex: 'tests\\/.*\\.spec\\.ts$',
8+
transform: {
9+
'\\.(ts|tsx)$': 'ts-jest',
10+
},
11+
verbose: true,
12+
};
13+
14+
export default !!process.env.E2E ?
15+
{
16+
...baseOption,
17+
preset: 'jest-puppeteer',
18+
testRegex: 'tests\\/.*\\.e2e\\.ts$',
19+
testTimeout: 60000,
20+
} : {
21+
...baseOption,
22+
transform: {
23+
...baseOption.transform,
24+
'\\.scss$': 'jest-transform-stub',
25+
}
26+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
declare module '*.css';
2+
declare module '*.scss';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@ovhcloud/ods-react-combobox",
3+
"version": "18.6.3",
4+
"private": true,
5+
"description": "ODS React Combobox component",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"scripts": {
9+
"clean": "rimraf documentation node_modules",
10+
"doc": "typedoc",
11+
"lint:a11y": "eslint --config ../../../../../.eslintrc-a11y 'src/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'",
12+
"lint:scss": "stylelint --aei 'src/components/**/*.scss'",
13+
"lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'",
14+
"start": "npm run start:storybook",
15+
"start:storybook": "storybook dev -p 3000 --no-open",
16+
"test:e2e": "E2E=true start-server-and-test 'npm run start:storybook' 3000 'jest -i --detectOpenHandles'",
17+
"test:e2e:ci": "CI=true npm run test:e2e",
18+
"test:spec": "jest 'tests/.*.spec.ts$' --passWithNoTests",
19+
"test:spec:ci": "npm run test:spec"
20+
}
21+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { ComponentPropsWithRef, FC } from 'react';
2+
import { Portal } from '@ark-ui/react';
3+
import { Combobox as VendorCombobox, useComboboxContext } from '@ark-ui/react/combobox';
4+
import classNames from 'classnames';
5+
import { forwardRef, useMemo } from 'react';
6+
import { useCombobox } from '../../context/useCombobox';
7+
import { combobox } from '../../controller/combobox';
8+
import { ComboboxItem, type ComboboxItemOrGroup } from '../combobox-item/ComboboxItem';
9+
import style from './comboboxContent.module.scss';
10+
11+
interface ComboboxContentProp extends ComponentPropsWithRef<'div'> {
12+
className?: string;
13+
}
14+
15+
const ComboboxContent: FC<ComboboxContentProp> = forwardRef(({
16+
className,
17+
...props
18+
}, ref): JSX.Element => {
19+
const { items, noResultLabel, customOptionRenderer, allowCustomValue, newElementLabel, value } = useCombobox();
20+
const { inputValue, value: contextValue = [] } = useComboboxContext();
21+
22+
const {
23+
filteredItems,
24+
} = combobox({
25+
allowCustomValue,
26+
customOptionRenderer,
27+
inputValue,
28+
items,
29+
newElementLabel,
30+
value: (value ?? []).length > 0 ? value : contextValue,
31+
});
32+
33+
const content = useMemo(() => {
34+
const hasOnlyNewElement = filteredItems.length === 1 && !('options' in filteredItems[0]) && filteredItems[0].isNewElement;
35+
const hasNoResults = !filteredItems.length;
36+
37+
if (hasNoResults) {
38+
return <div className={style['combobox-content__empty']}>{noResultLabel}</div>;
39+
}
40+
41+
const itemsToDisplay = hasOnlyNewElement ? [] : filteredItems;
42+
43+
return (
44+
<>
45+
{hasOnlyNewElement && !('options' in filteredItems[0]) && (
46+
<ComboboxItem
47+
key={filteredItems[0].value}
48+
item={filteredItems[0]}
49+
/>
50+
)}
51+
{itemsToDisplay.map((item) => {
52+
if ('options' in item) {
53+
return (
54+
<div key={item.label} className={style['combobox-content__group']}>
55+
<div className={style['combobox-content__group__label']}>{item.label}</div>
56+
{item.options.map((option: ComboboxItemOrGroup) => {
57+
if ('options' in option) {
58+
return null;
59+
}
60+
return (
61+
<ComboboxItem
62+
key={option.value}
63+
item={option}
64+
/>
65+
);
66+
})}
67+
</div>
68+
);
69+
}
70+
return (
71+
<ComboboxItem
72+
key={item.value}
73+
item={item}
74+
/>
75+
);
76+
})}
77+
{hasOnlyNewElement && (
78+
<div className={style['combobox-content__empty']}>{noResultLabel}</div>
79+
)}
80+
</>
81+
);
82+
}, [filteredItems, inputValue, noResultLabel]);
83+
84+
return (
85+
<Portal>
86+
<VendorCombobox.Positioner>
87+
<VendorCombobox.Content
88+
className={classNames(style['combobox-content'], className)}
89+
ref={ref}
90+
{...props}
91+
>
92+
{content}
93+
</VendorCombobox.Content>
94+
</VendorCombobox.Positioner>
95+
</Portal>
96+
);
97+
});
98+
99+
ComboboxContent.displayName = 'ComboboxContent';
100+
101+
export {
102+
ComboboxContent,
103+
type ComboboxContentProp,
104+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@use '../../../../../style/focus';
2+
@use '../../../../../style/overlay';
3+
@use '../../../../../style/state';
4+
5+
@layer ods-organisms {
6+
.combobox-content {
7+
box-sizing: border-box;
8+
z-index: overlay.$ods-overlay-select-z-index;
9+
margin: 0;
10+
border: 1px solid rgba(0, 0, 0, 0.15);
11+
border-radius: var(--ods-border-radius-sm);
12+
background: var(--ods-color-primary-000);
13+
padding: 0;
14+
color: var(--ods-color-text);
15+
16+
&__empty {
17+
display: flex;
18+
align-items: center;
19+
padding: 0 8px;
20+
min-height: 32px;
21+
color: var(--ods-color-text);
22+
}
23+
24+
&__group {
25+
margin: 0;
26+
padding: 0;
27+
28+
[data-part="item"] {
29+
padding: 0 16px;
30+
}
31+
32+
&__label {
33+
display: flex;
34+
align-items: center;
35+
padding: 0 8px;
36+
min-height: 32px;
37+
color: var(--ods-color-heading);
38+
font-weight: 600;
39+
}
40+
41+
&[data-disabled] {
42+
@include state.ods-is-disabled();
43+
}
44+
}
45+
}
46+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ComponentPropsWithRef, FC } from 'react';
2+
import { Combobox as VendorCombobox, useComboboxContext } from '@ark-ui/react/combobox';
3+
import classNames from 'classnames';
4+
import { forwardRef } from 'react';
5+
import { Input } from '../../../../input/src';
6+
import style from './comboboxControl.module.scss';
7+
8+
interface ComboboxControlProp extends ComponentPropsWithRef<'button'> {
9+
className?: string;
10+
clearable?: boolean;
11+
loading?: boolean;
12+
placeholder?: string;
13+
}
14+
15+
const ComboboxControl: FC<ComboboxControlProp> = forwardRef(({
16+
className,
17+
clearable = false,
18+
loading = false,
19+
placeholder = '',
20+
...props
21+
}, ref): JSX.Element => {
22+
const { valueAsString, getContentProps } = useComboboxContext();
23+
const contentProps = getContentProps() as {
24+
'data-placement'?: 'bottom' | 'top';
25+
'data-state'?: 'open' | 'closed';
26+
};
27+
const placement = contentProps[ 'data-placement' ] as 'top' | 'bottom' | undefined;
28+
const isOpen = contentProps[ 'data-state' ] === 'open';
29+
30+
return (
31+
<VendorCombobox.Control className={ classNames(style[ 'combobox-control' ], className, {
32+
[ style[ 'combobox-control--open-top' ] ]: isOpen && placement === 'top',
33+
[ style[ 'combobox-control--open-bottom' ] ]: isOpen && placement === 'bottom',
34+
}) }>
35+
<VendorCombobox.Trigger
36+
className={ classNames(style[ 'combobox-control__trigger' ], className) }
37+
ref={ ref }
38+
{ ...props }
39+
>
40+
<VendorCombobox.Input defaultValue={ valueAsString } asChild>
41+
<Input
42+
className={ classNames(
43+
style[ 'combobox-control__input' ],
44+
) }
45+
clearable={ clearable }
46+
loading={ loading }
47+
placeholder={ placeholder }
48+
/>
49+
</VendorCombobox.Input>
50+
</VendorCombobox.Trigger>
51+
</VendorCombobox.Control>
52+
);
53+
});
54+
55+
export { ComboboxControl, type ComboboxControlProp };

0 commit comments

Comments
 (0)