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
7 changes: 7 additions & 0 deletions packages/cmsui/config/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DateTimePicker,
SizeWidget,
WidthWidget,
SelectWidget,
} from '@plone/components/quanta';
import { DateField } from '@plone/components';

Expand All @@ -30,5 +31,11 @@ export default function install(config: ConfigType) {
definition: { width: WidthWidget },
});

config.registerWidget({
key: 'factory',
definition: { Choice: SelectWidget },
});
//console.log(config.widgets);

return config;
}
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
141 changes: 141 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,141 @@
import React, { useState } from 'react';
import { SelectWidget, 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: SelectWidget,
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-[600px]">
<SelectWidget {...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-[600px] space-y-4">
<SelectWidget
{...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',
};
181 changes: 181 additions & 0 deletions packages/components/src/components/Select/Select.quanta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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,
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" />
<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',
onBlur,
onFocus,
...props
}: SelectListProps<T>) => {
return (
<Popover
className="z-50 w-[var(--trigger-width)] overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"
placement={placement}
>
<Dialog role="dialog" className="w-[var(--trigger-width)] outline-none">
<ListBox
className={twMerge('max-h-60 overflow-auto outline-none', className)}
items={items}
onBlur={onBlur}
onFocus={onFocus}
>
{(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'];
choices?: string[]; // alternative to items
onChange?: (value: Key | null) => void;
onBlur?: () => void;
onFocus?: () => void;
}

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
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>
);
}

export function SelectWidget<T extends Option>({
choices,
items,
...props
}: SelectProps<T>) {
let selectItems = items;

// If choices are provided, map them to the items format.
if (choices && choices.length > 0) {
selectItems = choices.map(
(item) =>
({
id: item,
name: item,
}) as T,
);
}

return <Select {...props} items={selectItems} />;
}
1 change: 1 addition & 0 deletions packages/components/src/quanta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from '../components/RadioGroup/RadioGroup.quanta';
export * from '../components/MultiSelect/MultiSelect.quanta';
export * from '../components/Separator/Separator.quanta';
export * from '../components/Tabs/Tabs.quanta';
export { SelectWidget } from '../components/Select/Select.quanta';
Loading