# AppSelect

## Overview

A flexible select component supporting both single and multiple selection modes with advanced features including search, grouping, custom labels (JSX), clear actions, chips display, and optional item creation. Built on top of Command and Popover components.

---

## Types

### AppSelectOption

```ts
export interface AppSelectOption {
  value: string | number; // Unique identifier for the option
  label: string | React.ReactNode; // Display label (can be JSX for custom rendering)
  disabled?: boolean; // Disables selection of this option
  group?: string; // Group name for grouping options
  fixed?: boolean; // In chip mode, prevents removal of this option
}
```

---

## Props

The component uses discriminated union types based on the `multiple` prop.

### Base Props (Common to both modes)

| Prop                 | Type                           | Default                                          | Description                                                 |
| -------------------- | ------------------------------ | ------------------------------------------------ | ----------------------------------------------------------- |
| `options`            | `AppSelectOption[]`            | **required**                                     | Array of selectable options                                 |
| `placeholder`        | `string`                       | `"Seleziona..."`                                 | Text shown in trigger when no selection                     |
| `searchPlaceholder`  | `string`                       | `"Cerca..."`                                     | Placeholder for search input                                |
| `emptyPlaceholder`   | `string`                       | `"Nessun risultato"`                             | Text shown when no options match search                     |
| `addItemPlaceholder` | `string`                       | `"Aggiungi"`                                     | Text prefix for creating new items                          |
| `noGroupLabel`       | `string`                       | `"Nessun gruppo"`                                | Label shown for options without a group                     |
| `itemCountMessage`   | `(selected: number) => string` | `(n) => "${n} elementi selezionati"`             | Message function for selected count display                 |
| `maxSelectedMessage` | `(max: number) => string`      | `(m) => "Puoi selezionare fino a ${m} elementi"` | Message shown when max selection reached                    |
| `label`              | `string \| React.ReactNode`    | `undefined`                                      | Label displayed above the select                            |
| `labelClassName`     | `string`                       | `""`                                             | Additional Tailwind classes for the label                   |
| `className`          | `string`                       | `""`                                             | Additional Tailwind classes for the trigger                 |
| `wrpClassName`       | `string`                       | `""`                                             | Additional Tailwind classes for the wrapper                 |
| `searchable`         | `boolean`                      | `false`                                          | Enables search/filter functionality                         |
| `creatable`          | `boolean`                      | `false`                                          | Allows creating new options from search input               |
| `disabled`           | `boolean`                      | `false`                                          | Disables the entire select component                        |
| `size`               | `"sm" \| "default" \| "lg"`    | `"default"`                                      | Size variant affecting height and text size                 |
| `onClear`            | `() => void`                   | `undefined`                                      | Callback fired when clear button is clicked                 |
| `id`                 | `string`                       | `undefined`                                      | HTML id attribute for the trigger (useful for testing)      |
| `data-testid`        | `string`                       | `undefined`                                      | Test identifier attribute for E2E testing (e.g. Playwright) |

### Single Select Props

```ts
export type SingleSelectProps = BaseProps & {
  multiple?: false; // Single selection mode (default)
  value?: string | number; // Controlled value
  defaultValue?: string | number; // Uncontrolled default value
  onValueChange?: (value: string | number | undefined) => void; // Value change callback
  renderValue?: (option: AppSelectOption) => React.ReactNode; // Custom render for selected value
  isSingleSelectClearable?: boolean; // Shows clear button (default: false)
};
```

### Multi Select Props

```ts
export type MultiSelectProps = BaseProps & {
  multiple: true; // Multiple selection mode
  value?: (string | number)[]; // Controlled values array
  defaultValue?: (string | number)[]; // Uncontrolled default values
  onValueChange?: (value: (string | number)[]) => void; // Value change callback
  renderValue?: (option: AppSelectOption) => React.ReactNode; // Custom render for selected values
  maxSelected?: number; // Maximum number of selectable items
  showChipsInsteadOfCount?: boolean; // Display chips instead of count (default: false)
  selectableAll?: boolean; // Shows tri-state select-all toggle next to the search bar (default: true)
};
```

### Combined Type

```ts
export type AppSelectProps = SingleSelectProps | MultiSelectProps;
```

---

## Prop Details

### Size Variants

The `size` prop controls the trigger height and text size:

- **`sm`**: `min-h-8 h-8 text-xs py-1.5`
- **`default`**: `min-h-9 h-9 text-sm py-2`
- **`lg`**: `min-h-10 h-10 text-base py-2`

### Custom Labels (JSX Support)

The `label` property in `AppSelectOption` supports both strings and React nodes, allowing for rich custom rendering with icons, badges, or any JSX content. When using JSX labels, the component properly handles them without wrapping issues (React 19 compatible).

### Controlled vs Uncontrolled

- **Controlled**: Component is controlled when the `value` prop is provided (checked via `props.hasOwnProperty('value')`)
- **Uncontrolled**: Uses internal state when only `defaultValue` is provided
- The component remains in its initial mode throughout its lifecycle

---

## Behavior

### Selection Modes

#### Single Select

- Clicking an option selects it and closes the popover
- Only one option can be selected at a time
- Clear button appears when `isSingleSelectClearable={true}` and a value is selected
- Clearing sets value to `undefined`

#### Multiple Select

- Clicking an option toggles its selection state
- Popover remains open for multiple selections
- Selected count or chips displayed in trigger
- Clear button always visible when selections exist
- Clearing sets value to empty array `[]`

### Search Functionality

When `searchable={true}`:

- Search input appears at the top of the popover
- Options are filtered client-side based on label text
- Search is case-insensitive
- Empty state shows `emptyPlaceholder` when no matches

### Creatable Options

When `creatable={true}`:

- Requires `searchable={true}` to work
- Shows "Aggiungi '[input]'" option when search text doesn't match existing options
- Selecting the create option adds the input text as a new value
- In single mode, closes popover after creation
- In multiple mode, keeps popover open for more selections

### Grouping

When options have a `group` property:

- Options are automatically grouped under headings
- Groups are rendered in the order they appear in the options array
- Options without a group value (empty string, undefined, or missing) appear under the `noGroupLabel` heading
- Grouping is based on the `group` property of `AppSelectOption`

### Select All / Deselect All

In multiple mode with `selectableAll={true}` (default):

- A single tri-state checkbox-style toggle appears at the left of the search bar — replacing the search icon (or in a slim header bar when `searchable` is off)
- **`Square`** icon → no filtered options selected; click selects all matching the current filter (respects `maxSelected` cap)
- **`SquareMinus`** icon → some filtered options selected; click completes the selection (within the cap)
- **`SquareCheck`** icon → all filtered options selected (or `maxSelected` cap reached); click deselects only the currently-filtered items — **out-of-view selections and `fixed: true` items are preserved**
- **`SquareDot`** icon (disabled) → search yields no matches; the button stays visible but inactive so the layout doesn't collapse
- Both actions are scoped to the current search filter — symmetric, predictable, and prevents accidental data loss
- Uses `role="checkbox"` with dynamic `aria-checked` (`true` / `false` / `"mixed"`) and a hardcoded English `aria-label` (no per-project i18n required)
- Available only on multi-select; type-rejected on single-select. Opt out with `selectableAll={false}`

### Max Selection Limit

In multiple mode with `maxSelected` set:

- Prevents selecting more than the specified number of items
- Unselected options become disabled when limit is reached
- Footer message appears showing the limit via `maxSelectedMessage`
- Selected items can still be deselected

### Chips Display

In multiple mode with `showChipsInsteadOfCount={true}`:

- Shows individual badge chips for each selected item
- Each chip has an X button to remove the item
- Options with `fixed={true}` show chips without X button (cannot be removed)
- Chips are scrollable horizontally if they overflow

### Disabled Options

Options with `disabled={true}`:

- Appear grayed out with reduced opacity
- Cannot be selected or deselected
- Cursor changes to `not-allowed`

### Disabled Component

When `disabled={true}`:

- Entire component is non-interactive
- Trigger shows reduced opacity
- Popover cannot be opened
- Cursor changes to `not-allowed`

---

## Examples

### Basic Single Select

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: "react", label: "React" },
  { value: "vue", label: "Vue" },
  { value: "angular", label: "Angular" },
];

export function BasicSelect() {
  const [value, setValue] = useState<string | number | undefined>();

  return (
    <AppSelect
      options={options}
      value={value}
      onValueChange={setValue}
      placeholder="Select a framework"
      label="Framework"
    />
  );
}
```

### Multiple Select with Search and Chips

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: 1, label: "React" },
  { value: 2, label: "Vue" },
  { value: 3, label: "Angular" },
  { value: 4, label: "Svelte" },
];

export function MultiSelectWithChips() {
  const [values, setValues] = useState<(string | number)[]>([]);

  return (
    <AppSelect
      multiple
      options={options}
      value={values}
      onValueChange={setValues}
      placeholder="Select frameworks"
      label="Frameworks"
      searchable
      showChipsInsteadOfCount
    />
  );
}
```

### Grouped Options with Max Selection

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: "react", label: "React", group: "Frontend" },
  { value: "vue", label: "Vue", group: "Frontend" },
  { value: "node", label: "Node.js", group: "Backend" },
  { value: "django", label: "Django", group: "Backend" },
];

export function GroupedSelect() {
  const [values, setValues] = useState<(string | number)[]>([]);

  return (
    <AppSelect
      multiple
      options={options}
      value={values}
      onValueChange={setValues}
      placeholder="Select up to 2 technologies"
      label="Tech Stack"
      maxSelected={2}
      searchable
    />
  );
}
```

### Creatable Select

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const initialOptions = [
  { value: "tag1", label: "Tag 1" },
  { value: "tag2", label: "Tag 2" },
];

export function CreatableSelect() {
  const [values, setValues] = useState<(string | number)[]>([]);

  return (
    <AppSelect
      multiple
      options={initialOptions}
      value={values}
      onValueChange={setValues}
      placeholder="Create or select tags"
      label="Tags"
      searchable
      creatable
      showChipsInsteadOfCount
    />
  );
}
```

### Custom JSX Labels

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  {
    value: "react",
    label: (
      <div className="flex items-center gap-2">
        <span className="text-blue-600">⚛</span>
        React
      </div>
    ),
  },
  {
    value: "vue",
    label: (
      <div className="flex items-center gap-2">
        <span className="text-green-600">●</span>
        Vue
      </div>
    ),
  },
  {
    value: "angular",
    label: (
      <div className="flex items-center gap-2">
        <span className="text-red-600">▲</span>
        Angular
      </div>
    ),
  },
];

export function CustomLabelsSelect() {
  const [value, setValue] = useState<string | number | undefined>();

  return (
    <AppSelect
      options={options}
      value={value}
      onValueChange={setValue}
      placeholder="Select a framework"
      label="Framework with Icons"
    />
  );
}
```

### Clearable Single Select

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: 1, label: "Option 1" },
  { value: 2, label: "Option 2" },
  { value: 3, label: "Option 3" },
];

export function ClearableSelect() {
  const [value, setValue] = useState<string | number | undefined>(1);

  return (
    <AppSelect
      options={options}
      value={value}
      onValueChange={setValue}
      placeholder="Select an option"
      label="Status"
      isSingleSelectClearable
      onClear={() => console.log("Cleared!")}
    />
  );
}
```

### With Disabled Options

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: "it", label: "Italia" },
  { value: "fr", label: "Francia" },
  { value: "de", label: "Germania" },
  { value: "uk", label: "Regno Unito", disabled: true },
];

export function SelectWithDisabled() {
  const [value, setValue] = useState<string | number | undefined>();

  return (
    <AppSelect
      options={options}
      value={value}
      onValueChange={setValue}
      placeholder="Select a country"
      label="Country"
    />
  );
}
```

### Multiple with Select All / Deselect All

```tsx
import { AppSelect } from "laif-ds";
import { useState } from "react";

const options = [
  { value: "react", label: "React" },
  { value: "vue", label: "Vue" },
  { value: "angular", label: "Angular" },
  { value: "svelte", label: "Svelte" },
];

export function SelectAllExample() {
  const [values, setValues] = useState<(string | number)[]>([]);

  return (
    <AppSelect
      multiple
      selectableAll
      searchable
      options={options}
      value={values}
      onValueChange={setValues}
      placeholder="Select frameworks"
      label="Frameworks"
    />
  );
}
```

### Custom Render Value

```tsx
import { AppSelect, AppSelectOption } from "laif-ds";
import { Badge } from "laif-ds";
import { useState } from "react";

const options = [
  { value: "react", label: "React" },
  { value: "vue", label: "Vue" },
  { value: "angular", label: "Angular" },
];

export function CustomRenderSelect() {
  const [value, setValue] = useState<string | number | undefined>("react");

  return (
    <AppSelect
      options={options}
      value={value}
      onValueChange={setValue}
      placeholder="Select a framework"
      label="Framework"
      renderValue={(option: AppSelectOption) => (
        <Badge variant="success">{option.label}</Badge>
      )}
    />
  );
}
```

---

## Notes

- **React 19 Compatible**: Properly handles JSX labels without ref warnings
- **Responsive Width**: Popover automatically matches trigger width
- **Keyboard Navigation**: Full keyboard support via Command component
- **Accessibility**: Proper ARIA attributes and focus management
- **Performance**: Efficient rendering with useMemo for computed values
- **Custom Rendering**: Use `renderValue` prop to customize how selected options appear in the trigger
- **Controlled Component Detection**: Uses `props.hasOwnProperty('value')` to determine if component is controlled, ensuring stable behavior even when value is `undefined`
- **Mobile Keyboard**: Popover prevents default auto-focus on open via `onOpenAutoFocus`, so the soft keyboard does not pop up automatically when `searchable` is enabled. This avoids the visual-viewport collapse that previously caused the dropdown to flip off-screen on mobile. Tap the search input to focus it explicitly when needed.
