Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions packages/components/news/7345.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Select widget for Plone 7 @dobri1408
137 changes: 137 additions & 0 deletions packages/components/src/components/Select/Select.quanta.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { Select, type Option, type SelectProps } from './Select.quanta';
import { type Key } from 'react-aria-components';

const fruits: Option[] = [
{ id: 'apple', name: 'Apple' },
{ id: 'banana', name: 'Banana' },
{ id: 'orange', name: 'Orange' },
{ id: 'strawberry', name: 'Strawberry' },
{ id: 'blueberry', name: 'Blueberry' },
{ id: 'raspberry', name: 'Raspberry' },
{ id: 'grape', name: 'Grape' },
{ id: 'watermelon', name: 'Watermelon' },
{ id: 'pineapple', name: 'Pineapple' },
{ id: 'mango', name: 'Mango' },
];

export default {
title: 'Quanta/Select',
component: Select,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
items: {
control: false,
description: 'The list of available options to display in the dropdown.',
},
label: { control: 'text', description: 'The label of the select field' },
placeholder: {
control: 'text',
description: 'Placeholder text displayed when no option is selected.',
},
description: {
control: 'text',
description: 'Additional helper text displayed below the field.',
},
errorMessage: {
control: 'text',
description: 'Error message to display below the field.',
},
isDisabled: {
control: 'boolean',
description: 'Whether the field is disabled.',
},
isRequired: {
control: 'boolean',
description: 'Whether the field is required.',
},
onChange: {
action: 'selection-changed',
description: 'Callback fired when selection changes.',
},
},
};

export const Default = (args: SelectProps<Option>) => {
return (
<div className="w-full max-w-[300px]">
<Select {...args} />
</div>
);
};

Default.args = {
label: 'Favorite Fruit',
placeholder: 'Choose a fruit...',
items: fruits,
onChange: (value: Key | null) => console.log('Selected:', value),
} as Partial<SelectProps<Option>>;

export const WithDefaultValue = Default.bind({});

WithDefaultValue.args = {
...Default.args,
label: 'Fruit with Default',
defaultSelectedKey: 'banana',
};

export const Disabled = Default.bind({});

Disabled.args = {
...Default.args,
label: 'Disabled Select',
isDisabled: true,
defaultSelectedKey: 'apple',
};

export const Required = Default.bind({});

Required.args = {
...Default.args,
label: 'Required Fruit',
description: 'This field is required',
isRequired: true,
};

export const WithError = Default.bind({});

WithError.args = {
...Default.args,
label: 'Select with Error',
errorMessage: 'Please select a fruit',
};

export const Controlled = (args: SelectProps<Option>) => {
const [selectedKey, setSelectedKey] = useState<Key | null>('orange');

const handleChange = (value: Key | null) => {
setSelectedKey(value);
};

return (
<div className="w-full max-w-[300px] space-y-4">
<Select {...args} selectedKey={selectedKey} onChange={handleChange} />
<div className="text-sm">
<strong>Currently Selected:</strong>
<div className="mt-1 rounded border bg-gray-50 p-2">
{selectedKey ? (
<span className="inline-block rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
{fruits.find((f) => f.id === selectedKey)?.name}
</span>
) : (
<span className="text-gray-500">Nothing selected</span>
)}
</div>
</div>
</div>
);
};

Controlled.args = {
...Default.args,
label: 'Controlled Select',
description: 'This demonstrates controlled state with onChange',
};
161 changes: 161 additions & 0 deletions packages/components/src/components/Select/Select.quanta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React from 'react';
import { Description, FieldError, Label } from '../Field/Field.quanta';
import { ChevrondownIcon } from '../../components/icons/ChevrondownIcon';
import { CheckboxIcon } from '../../components/icons/CheckboxIcon';
import { twMerge } from 'tailwind-merge';

import type {
ListBoxProps,
PopoverProps,
SelectProps as AriaSelectProps,
ValidationResult,
Key,
} from 'react-aria-components';
import {
Button,
Select as AriaSelect,
SelectValue,
composeRenderProps,
Popover,
Dialog,
ListBoxItem,
ListBox,
} from 'react-aria-components';

export type Option = {
id: Key;
name: string;
};

interface SelectTriggerProps {
className?: string;
placeholder?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
}

const SelectTrigger = ({
className,
placeholder,
errorMessage,
}: SelectTriggerProps) => {
return (
<Button
className={twMerge(
'flex h-10 w-full cursor-default items-center justify-between gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:ring-2 focus:ring-gray-400 disabled:opacity-50',
errorMessage
? 'border-red-500 focus:border-red-500 focus:ring-red-400'
: 'border-gray-300 focus:border-gray-500 focus:ring-gray-400',
className,
)}
>
<SelectValue
className="flex-1 truncate text-left data-[placeholder]:text-gray-500"
placeholder={placeholder}
/>
<ChevrondownIcon className="h-4 w-4 shrink-0 text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
);
};

interface SelectListProps<T extends Option>
extends Omit<ListBoxProps<T>, 'layout' | 'orientation'>,
Pick<PopoverProps, 'placement'> {
items?: Iterable<T>;
className?: string;
}

const SelectList = <T extends Option>({
items,
className,
placement = 'bottom',
...props
}: SelectListProps<T>) => {
return (
<Popover
className="z-50 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
placement={placement}
>
<Dialog role="dialog">
<ListBox
className={twMerge('max-h-60 overflow-auto', className)}
items={items}
{...props}
>
{(item) => (
<ListBoxItem
key={item.id}
id={item.id}
textValue={item.name}
className="cursor-pointer px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none data-[focused]:bg-gray-100"
>
{({ isSelected }) => (
<div className="flex w-full items-center">
<span className="flex-1">{item.name}</span>
{isSelected && (
<CheckboxIcon className="ml-2 h-4 w-4 text-gray-600" />
)}
</div>
)}
</ListBoxItem>
)}
</ListBox>
</Dialog>
</Popover>
);
};

export interface SelectProps<T extends Option>
extends Omit<AriaSelectProps<T>, 'children'> {
label?: string;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
placeholder?: string;
items?: Iterable<T>;
className?: string;
listBoxClassName?: string;
placement?: PopoverProps['placement'];
onChange?: (value: Key | null) => void;
onBlur?: () => void;
onFocus?: () => void;
}

export function Select<T extends Option>({
label,
description,
errorMessage,
placeholder = 'Select an option...',
className,
items,
listBoxClassName,
placement = 'bottom',
onChange,
onBlur,
onFocus,
...props
}: SelectProps<T>) {
return (
<AriaSelect
{...props}
onSelectionChange={onChange}
className={twMerge('group flex w-full flex-col gap-1.5', className)}
>
{label && <Label>{label}</Label>}
<SelectTrigger placeholder={placeholder} errorMessage={errorMessage} />
<SelectList
items={items}
className={listBoxClassName}
placement={placement}
onBlur={onBlur}
onFocus={onFocus}
/>
{description && <Description>{description}</Description>}
{errorMessage && (
<div className="text-sm text-red-600">
{typeof errorMessage === 'function'
? errorMessage({} as ValidationResult)
: errorMessage}
</div>
)}
</AriaSelect>
);
}
Loading