Skip to content
Merged
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
6 changes: 4 additions & 2 deletions packages/landing/src/routes/contact.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ export default function Contact() {
<div>
<h2 class='mb-6 text-2xl font-semibold text-gray-900'>We are here to help</h2>
<p class='mb-8 text-gray-600'>
Whether you have questions about our platform, need technical support, or want
to discuss partnership opportunities, our team is ready to assist you.
Whether you have questions about our platform, need technical support, want to
provide feedback, or discuss partnership opportunities, our team is ready to
assist you. If you would like early access to the platform, please fill out the
form below with 'Early Access Request' as the subject.
</p>
</div>

Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/constants/zIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const Z_INDEX = {
MENU: 'z-40',
COMBOBOX: 'z-40',

/** Select - Dropdown select (needs to be above dialogs) */
SELECT: 'z-[60]',

/** Dialog and Drawer Backdrop - Modal backdrops */
BACKDROP: 'z-50',

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,8 @@ export interface SelectProps {
placeholder?: string;
/** Disable the select */
disabled?: boolean;
/** Set to true when used inside a Dialog or Popover */
inDialog?: boolean;
}

export const Select: Component<SelectProps>;
Expand Down
283 changes: 195 additions & 88 deletions packages/ui/src/zag/Select.jsx
Original file line number Diff line number Diff line change
@@ -1,136 +1,243 @@
/**
* Select - A custom select/dropdown component using Ark UI
* Select component using Ark UI
*
* Supports both high-level convenience API and low-level composition API
*/

import { Select, createListCollection } from '@ark-ui/solid/select';
import { Select as ArkSelect, createListCollection, useSelect } from '@ark-ui/solid/select';
import { Portal } from 'solid-js/web';
import { createMemo, Show, Index } from 'solid-js';
import { createMemo, Show, Index, splitProps, mergeProps } from 'solid-js';
import { BiRegularCheck, BiRegularChevronDown } from 'solid-icons/bi';
import { Z_INDEX } from '../constants/zIndex.js';

/**
* Select - A custom select/dropdown component
* Select - Reusable select/dropdown component using Ark UI
*
* Props:
* - items: Array<{ label: string, value: string, disabled?: boolean }> - Options to display
* - value: string - The selected value (controlled)
* - onChange: (value: string) => void - Callback when value changes
* - label: string - Label text for the select
* - placeholder: string - Placeholder text when no value selected
* - disabled: boolean - Whether the select is disabled
* - name: string - Form input name
* - invalid: boolean - Whether the select is in an invalid state
* - disabledValues: string[] - Array of values that should be disabled
* - inDialog: boolean - Set to true when using inside a Dialog to avoid Portal issues
* @param {Object} props
* @param {Array<{ label: string, value: string, disabled?: boolean }>} props.items - Options to display
* @param {string} [props.value] - The selected value (controlled)
* @param {Function} [props.onChange] - Callback when value changes: (value: string) => void
* @param {string} [props.label] - Label text for the select
* @param {string} [props.placeholder] - Placeholder text when no value selected (default: 'Select option')
* @param {boolean} [props.disabled] - Whether the select is disabled
* @param {string} [props.name] - Form input name
* @param {boolean} [props.invalid] - Whether the select is in an invalid state
* @param {string[]} [props.disabledValues] - Array of values that should be disabled
* @param {boolean} [props.deselectable] - Whether the value can be cleared by clicking the selected item
* @param {boolean} [props.closeOnSelect] - Whether the select should close after an item is selected (default: true)
* @param {boolean} [props.multiple] - Whether to allow multiple selection
* @param {Object} [props.positioning] - Positioning options for the dropdown menu
* @param {boolean} [props.inDialog] - Set to true when used inside a Dialog or Popover
* @param {Object} [props.arkProps] - Additional props to pass to Select.Root (e.g., open, onOpenChange, etc.)
*/
export default function SelectComponent(props) {
// Split convenience props from Ark UI props
const [local, arkProps] = splitProps(props, [
'items',
'value',
'onChange',
'label',
'placeholder',
'disabledValues',
'deselectable',
'closeOnSelect',
'multiple',
'positioning',
'inDialog',
]);

// Merge default values
const merged = mergeProps(
{
items: [],
placeholder: 'Select option',
disabled: false,
disabledValues: [],
deselectable: false,
closeOnSelect: true,
multiple: false,
invalid: false,
inDialog: false,
},
props,
);

const items = () => props.items || [];
// Access value reactively from props to maintain reactivity
const value = () => props.value;
const placeholder = () => props.placeholder || 'Select option';
const disabled = () => props.disabled || false;
const disabledValues = () => props.disabledValues || [];
const inDialog = () => props.inDialog || false;

// Create collection from items
const collection = createMemo(() =>
createListCollection({
items: items(),
const disabledValues = createMemo(() => props.disabledValues || []);
const placeholder = () => props.placeholder || merged.placeholder;
const disabled = () => merged.disabled;
const invalid = () => merged.invalid;
const inDialog = () => merged.inDialog;

// Create collection from items with disabled handling
const collection = createMemo(() => {
const collectionItems = items().map(item => ({
...item,
disabled: item.disabled || disabledValues().includes(item.value),
}));

return createListCollection({
items: collectionItems,
itemToString: item => item.label,
itemToValue: item => item.value,
}),
);
});
});

// Convert single value to array for Ark UI
// Convert single value to array for Ark UI (or use array directly for multiple)
// Access value reactively to ensure proper tracking
const selectValue = createMemo(() => {
const v = value();
return v ? [v] : [];
if (merged.multiple) {
return (
Array.isArray(v) ? v
: v != null ? [v]
: []
);
}
// For single select: return empty array if value is null/undefined, otherwise [value]
// Empty string is valid (for "Unassigned" option)
return v != null ? [v] : [];
});

// Handle value change - convert array back to single value for single select
const handleValueChange = details => {
const newValue = details.value[0] || '';
// Prevent selecting disabled values
const item = items().find(i => i.value === newValue);
if (item?.disabled || disabledValues().includes(newValue)) {
return;
if (!local.onChange) return;

if (merged.multiple) {
local.onChange(details.value);
} else {
const newValue = details.value[0] || '';
// Prevent selecting disabled values
const currentCollection = collection();
const item = currentCollection.items.find(i => i.value === newValue);
if (item?.disabled || disabledValues().includes(newValue)) {
return;
}
local.onChange(newValue);
}
props.onChange?.(newValue);
};

// Helper to check if a value is disabled
const isValueDisabled = val => {
const item = items().find(i => i.value === val);
return item?.disabled || disabledValues().includes(val);
const currentCollection = collection();
const item = currentCollection.items.find(i => i.value === val);
const disabledSet = new Set(disabledValues());
return item?.disabled || disabledSet.has(val);
};

// Render content with or without portal
const renderContent = () => (
<Select.Positioner class={inDialog() ? 'absolute top-full right-0 left-0 z-10' : ''}>
<Select.Content class='max-h-60 overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg focus:outline-none'>
<Select.ItemGroup>
<Index each={items()}>
{item => {
const isDisabled = createMemo(() => isValueDisabled(item().value));
return (
<Select.Item
item={item()}
disabled={isDisabled()}
class={`flex cursor-pointer items-center justify-between px-3 py-2 whitespace-nowrap hover:bg-gray-100 data-[highlighted]:bg-blue-50 ${
isDisabled() ?
'cursor-not-allowed text-gray-400 hover:bg-transparent'
: 'text-gray-900'
}`}
>
<Select.ItemText>{item().label}</Select.ItemText>
<Select.ItemIndicator>
<BiRegularCheck class='h-5 w-5 text-blue-600' />
</Select.ItemIndicator>
</Select.Item>
);
}}
</Index>
</Select.ItemGroup>
</Select.Content>
</Select.Positioner>
);
// Get positioning options - use provided or default
const positioningOptions = () =>
local.positioning || {
placement: 'bottom-start',
sameWidth: true,
};

return (
<Select.Root
<ArkSelect.Root
collection={collection()}
value={selectValue()}
onValueChange={handleValueChange}
deselectable={merged.deselectable}
closeOnSelect={merged.closeOnSelect}
multiple={merged.multiple}
positioning={positioningOptions()}
disabled={disabled()}
invalid={props.invalid}
name={props.name}
class='relative'
invalid={invalid()}
{...arkProps}
>
<Show when={props.label}>
<Select.Label class='mb-1 block text-sm font-medium text-gray-700'>
{props.label}
</Select.Label>
<Show when={local.label}>
<ArkSelect.Label class='mb-1 block text-sm font-medium text-gray-700'>
{local.label}
</ArkSelect.Label>
</Show>

<Select.Control>
<Select.Trigger
<ArkSelect.Control>
<ArkSelect.Trigger
class={`flex w-full items-center justify-between rounded-md border px-3 py-2 text-left shadow-sm transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none ${
disabled() ?
'cursor-not-allowed border-gray-200 bg-gray-100 text-gray-500'
: 'border-gray-300 bg-white hover:border-gray-400'
} ${props.invalid ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
} ${invalid() ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
>
<Select.ValueText placeholder={placeholder()} />
<Select.Indicator>
<ArkSelect.ValueText placeholder={placeholder()} />
<ArkSelect.Indicator>
<BiRegularChevronDown class='h-5 w-5 text-gray-400 transition-transform data-[state=open]:rotate-180' />
</Select.Indicator>
</Select.Trigger>
</Select.Control>
</ArkSelect.Indicator>
</ArkSelect.Trigger>
</ArkSelect.Control>

{/* When inside a dialog, don't use Portal to avoid focus trap issues */}
<Show when={inDialog()} fallback={<Portal>{renderContent()}</Portal>}>
{renderContent()}
</Show>
{inDialog() ?
<ArkSelect.Positioner>
<ArkSelect.Content
class={`${Z_INDEX.SELECT} max-h-60 overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg focus:outline-none`}
>
<ArkSelect.ItemGroup>
<Index each={collection().items}>
{item => {
const isDisabled = () => isValueDisabled(item().value);
return (
<ArkSelect.Item
item={item()}
disabled={isDisabled()}
class={`flex cursor-pointer items-center justify-between px-3 py-2 whitespace-nowrap hover:bg-gray-100 data-[highlighted]:bg-blue-50 ${
isDisabled() ?
'cursor-not-allowed text-gray-400 hover:bg-transparent'
: 'text-gray-900'
}`}
>
<ArkSelect.ItemText>{item().label}</ArkSelect.ItemText>
<ArkSelect.ItemIndicator>
<BiRegularCheck class='h-5 w-5 text-blue-600' />
</ArkSelect.ItemIndicator>
</ArkSelect.Item>
);
}}
</Index>
</ArkSelect.ItemGroup>
</ArkSelect.Content>
</ArkSelect.Positioner>
: <Portal>
<ArkSelect.Positioner>
<ArkSelect.Content
class={`${Z_INDEX.SELECT} max-h-60 overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg focus:outline-none`}
>
<ArkSelect.ItemGroup>
<Index each={collection().items}>
{item => {
const isDisabled = () => isValueDisabled(item().value);
return (
<ArkSelect.Item
item={item()}
disabled={isDisabled()}
class={`flex cursor-pointer items-center justify-between px-3 py-2 whitespace-nowrap hover:bg-gray-100 data-[highlighted]:bg-blue-50 ${
isDisabled() ?
'cursor-not-allowed text-gray-400 hover:bg-transparent'
: 'text-gray-900'
}`}
>
<ArkSelect.ItemText>{item().label}</ArkSelect.ItemText>
<ArkSelect.ItemIndicator>
<BiRegularCheck class='h-5 w-5 text-blue-600' />
</ArkSelect.ItemIndicator>
</ArkSelect.Item>
);
}}
</Index>
</ArkSelect.ItemGroup>
</ArkSelect.Content>
</ArkSelect.Positioner>
</Portal>
}

<Select.HiddenSelect />
</Select.Root>
<ArkSelect.HiddenSelect />
</ArkSelect.Root>
);
}

// Export hook for programmatic control
export { useSelect };

// Export component
export { SelectComponent as Select };
2 changes: 1 addition & 1 deletion packages/ui/src/zag/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export { Popover } from './Popover.jsx';
export { Progress } from './Progress.jsx';
export { default as QRCode } from './QRCode.jsx';
export { RadioGroup } from './RadioGroup.jsx';
export { default as Select } from './Select.jsx';
export { default as Select, useSelect } from './Select.jsx';
export { Splitter } from './Splitter.jsx';
export { default as Switch } from './Switch.jsx';
export { Tabs } from './Tabs.jsx';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,6 @@ describe('createReconciledChecklist', () => {

expect(reconciled.name).toBe('Reconciled');
expect(reconciled.id).toBe('reconciled-1');
expect(reconciled.isReconciled).toBe(true);
expect(reconciled.q1.answers[2]).toEqual([false, true]); // From reviewer 2
expect(reconciled.q2.answers).toEqual(cl1.q2.answers); // From reviewer 1
});
Expand Down
1 change: 0 additions & 1 deletion packages/web/src/AMSTAR2/checklist-compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ export function createReconciledChecklist(checklist1, checklist2, selections, me
reviewerName: metadata.reviewerName || 'Consensus',
createdAt: metadata.createdAt || new Date().toISOString().split('T')[0],
id: metadata.id || `reconciled-${Date.now()}`,
isReconciled: true,
sourceChecklists: [checklist1.id, checklist2.id],
};

Expand Down
1 change: 0 additions & 1 deletion packages/web/src/ROBINS-I/checklist-compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ export function createReconciledChecklist(checklist1, checklist2, selections, me
createdAt: metadata.createdAt || new Date().toISOString().split('T')[0],
id: metadata.id || `reconciled-${Date.now()}`,
checklistType: CHECKLIST_TYPES.ROBINS_I,
isReconciled: true,
sourceChecklists: [checklist1.id, checklist2.id],

// Copy structural elements from checklist1
Expand Down
Loading