Skip to content
Closed
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
26 changes: 26 additions & 0 deletions packages/ods-react/src/components/combobox/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
core: {
disableTelemetry: true,
disableWhatsNewNotifications: true,
},
docs: {
autodocs: false,
},
framework: '@storybook/react-vite',
previewHead: (head) => `
${head}
<style>
html, body {
font-family: "Source Sans Pro", "Trebuchet MS", "Arial", "Segoe UI", sans-serif;
}
</style>
`,
stories: [
'../src/dev.stories.tsx',
'../tests/**/*.stories.tsx',
],
};

export default config;
10 changes: 10 additions & 0 deletions packages/ods-react/src/components/combobox/.storybook/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { addons } from '@storybook/manager-api';

addons.register('custom-panel', (api) => {
api.togglePanel(false);
});

addons.setConfig({
enableShortcuts: false,
showToolbar: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type Preview } from '@storybook/react';
import '@ovhcloud/ods-themes/default';

const preview: Preview = {
parameters: {},
};

export default preview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const isCI = !!process.env.CI;

export default {
launch: {
headless: isCI,
slowMo: isCI ? 0 : 300,
product: 'chrome',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--disable-gpu",
'--font-render-hinting=none',
],
},
};
26 changes: 26 additions & 0 deletions packages/ods-react/src/components/combobox/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const baseOption = {
collectCoverage: false,
testPathIgnorePatterns: [
'node_modules/',
'dist/',
],
testRegex: 'tests\\/.*\\.spec\\.ts$',
transform: {
'\\.(ts|tsx)$': 'ts-jest',
},
verbose: true,
};

export default !!process.env.E2E ?
{
...baseOption,
preset: 'jest-puppeteer',
testRegex: 'tests\\/.*\\.e2e\\.ts$',
testTimeout: 60000,
} : {
...baseOption,
transform: {
...baseOption.transform,
'\\.scss$': 'jest-transform-stub',
}
};
2 changes: 2 additions & 0 deletions packages/ods-react/src/components/combobox/modules.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare module '*.css';
declare module '*.scss';
21 changes: 21 additions & 0 deletions packages/ods-react/src/components/combobox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@ovhcloud/ods-react-combobox",
"version": "18.6.2",
"private": true,
"description": "ODS React Combobox component",
"type": "module",
"main": "dist/index.js",
"scripts": {
"clean": "rimraf documentation node_modules",
"doc": "typedoc",
"lint:a11y": "eslint --config ../../../../../.eslintrc-a11y 'src/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'",
"lint:scss": "stylelint --aei 'src/components/**/*.scss'",
"lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}' --ignore-pattern '*.stories.tsx'",
"start": "npm run start:storybook",
"start:storybook": "storybook dev -p 3000 --no-open",
"test:e2e": "E2E=true start-server-and-test 'npm run start:storybook' 3000 'jest -i --detectOpenHandles'",
"test:e2e:ci": "CI=true npm run test:e2e",
"test:spec": "jest 'tests/.*.spec.ts$' --passWithNoTests",
"test:spec:ci": "npm run test:spec"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { FC } from 'react';
import { Combobox as VendorCombobox, useComboboxContext } from '@ark-ui/react/combobox';
import { Portal } from '@ark-ui/react/portal';
import classNames from 'classnames';
import { type JSX, forwardRef, useRef } from 'react';
import { useCombobox } from '../../context/useCombobox';
import { ComboboxGroup } from '../combobox-group/ComboboxGroup';
import { ComboboxOption } from '../combobox-option/ComboboxOption';
import style from './comboboxContent.module.scss';

interface ComboboxContentProp {
addNewElementLabel?: string;
className?: string;

[ key: string ]: unknown;
}

const ComboboxContent: FC<ComboboxContentProp> = forwardRef(({
className,
...props
}, ref): JSX.Element => {
const { collection } = useComboboxContext();
const localRef = useRef<HTMLDivElement>(null);
const contentRef = (ref as React.RefObject<HTMLDivElement>) || localRef;
const { addNewElementLabel, customOptionRenderer, noResultLabel } = useCombobox();

const hasEnabledOption = collection.items.some(
(item: Record<string, unknown>) => typeof item === 'object' && item !== null && !('disabled' in item && item.disabled) && !('isNew' in item && item.isNew),
);

return (
<Portal>
<VendorCombobox.Positioner>
<VendorCombobox.Content
className={ classNames(style[ 'combobox-content' ], className) }
ref={ contentRef }
{ ...props }
>
<VendorCombobox.List>
{ collection.size > 0 && ([...collection][ 0 ]?.isNew) ? (
<VendorCombobox.ItemGroup>
<ComboboxOption
addNewElementLabel={ addNewElementLabel }
customOptionRenderer={ customOptionRenderer }
item={ [...collection][ 0 ] }
key={ [...collection][ 0 ].value }
/>
</VendorCombobox.ItemGroup>
) : null }
{ collection.group().map(([groupLabel, groupItems]) => (
<ComboboxGroup key={ String(groupLabel) } groupLabel={ groupLabel }>
{ groupItems
.filter((item) => !item.isNew)
.map((item) => (
<ComboboxOption
addNewElementLabel={ addNewElementLabel }
customOptionRenderer={ customOptionRenderer }
isInGroup={ !!groupLabel }
item={ item }
key={ item.value }
/>
)) }
</ComboboxGroup>
)) }
</VendorCombobox.List>

{ !hasEnabledOption ? (
<div className={ style[ 'combobox-content__empty' ] }>{ noResultLabel }</div>
) : null }

</VendorCombobox.Content>
</VendorCombobox.Positioner>
</Portal>
);
},
);

ComboboxContent.displayName = 'ComboboxContent';

export {
ComboboxContent,
type ComboboxContentProp,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@use '../../../../../style/focus';
@use '../../../../../style/overlay';

@layer ods-organisms {
.combobox-content {
box-sizing: border-box;
z-index: overlay.$ods-overlay-select-z-index;
margin: 0;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: var(--ods-border-radius-sm);
background: var(--ods-color-primary-000);
padding: 0;
color: var(--ods-color-text);

&__empty {
display: flex;
align-items: center;
padding: 0 8px;
min-height: 32px;
color: var(--ods-color-text);
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Combobox as VendorCombobox, useComboboxContext as useVendorComboboxContext } from '@ark-ui/react/combobox';
import classNames from 'classnames';
import { type ComponentPropsWithRef, type FC, type JSX, forwardRef } from 'react';
import { Input } from '../../../../input/src';
import { useCombobox } from '../../context/useCombobox';
import style from './comboboxControl.module.scss';

interface ComboboxControlProp extends ComponentPropsWithRef<'button'> {
clearable?: boolean;
loading?: boolean;
placeholder?: string;
}

const ComboboxControl: FC<ComboboxControlProp> = forwardRef(({
className,
clearable = false,
loading = false,
placeholder,
...props
}, ref): JSX.Element | null => {
const vendorContext = useVendorComboboxContext();

const context = useCombobox();
const { setValue, setInputValue, inputValue } = context;

const { getContentProps } = vendorContext;
const contentProps = getContentProps() as {
'data-placement'?: 'bottom' | 'top';
'data-state'?: 'open' | 'closed';
};
const placement = contentProps[ 'data-placement' ] as 'bottom' | 'top' | undefined;
const isOpen = contentProps[ 'data-state' ] === 'open';

const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
const hasHighlighted = !!document.querySelector('[role="option"][data-highlighted]');
if (!hasHighlighted) {
event.preventDefault();
event.stopPropagation();
}
}
};

const handleClear = (): void => {
setValue && setValue([]);
setInputValue && setInputValue('');
};

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setInputValue && setInputValue(event.target.value);
};

return (
<VendorCombobox.Control
className={ classNames(
style[ 'combobox-control' ],
isOpen && placement === 'bottom' && style[ 'combobox-control--open-bottom' ],
isOpen && placement === 'top' && style[ 'combobox-control--open-top' ],
className,
) }
>
<VendorCombobox.Trigger
className={ classNames(style[ 'combobox-control__trigger' ], className) }
ref={ ref }
{ ...props }>
<VendorCombobox.Input asChild>
<Input
value={ inputValue }
onChange={ handleInputChange }
className={ style[ 'combobox-control__input' ] }
clearable={ clearable }
loading={ loading }
onClear={ handleClear }
onKeyDown={ handleInputKeyDown }
placeholder={ placeholder }
/>
</VendorCombobox.Input>
</VendorCombobox.Trigger>
</VendorCombobox.Control>
);
});

ComboboxControl.displayName = 'ComboboxControl';

export {
ComboboxControl,
type ComboboxControlProp,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@use '../../../../../style/focus';
@use '../../../../../style/input';
@use '../../../../../style/state';

@layer ods-organisms {
.combobox-control {
display: flex;
margin: 0;
border: var(--ods-border-width-sm) solid var(--ods-color-form-element-border-default);
border-radius: var(--ods-border-radius-sm);
width: 100%;

&--open-bottom {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}

&--open-top {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

&__trigger {
border: none;
background-color: transparent;
padding: 0;
width: 100%;
}

&__input {
outline: none;
border: none;
background: transparent;
width: 100%;
}

&[data-focus] {
@include focus.ods-focus();
}

&[data-disabled] {
@include state.ods-is-disabled();
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Combobox as VendorCombobox } from '@ark-ui/react/combobox';
import classNames from 'classnames';
import { type FC, type ReactNode } from 'react';
import style from './comboboxGroup.module.scss';

interface ComboboxGroupProp {
children: ReactNode;
className?: string;
groupLabel?: string;
}

const ComboboxGroup: FC<ComboboxGroupProp> = ({
children,
className,
groupLabel,
}) => (
<VendorCombobox.ItemGroup className={ classNames(style['combobox-group'], className) }>
{ groupLabel && (
<VendorCombobox.ItemGroupLabel className={ style['combobox-group__label'] }>
{ groupLabel }
</VendorCombobox.ItemGroupLabel>
) }
{ children }
</VendorCombobox.ItemGroup>
);

ComboboxGroup.displayName = 'ComboboxGroup';

export {
ComboboxGroup,
type ComboboxGroupProp,
};
Loading