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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Changelog
## [34.5.0] - 2026-10-08
- Add AutoComplete component (type-ahead select with id/label and object modes) and useEditableSearchGrid hook (shared in-grid search mechanics for a MUI DataGrid), each with tests, docs and a story/mdx.
## [34.4.0] - 2026-10-05
- Fix ReportDataGrid to embolden columns identified by headers.totalColumns, preserving total-row and cell-colour styling.
- Update LinkField display to match InputField display more closely and add toolTip parameter
Expand Down
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ import usePut from './src/hooks/usePut.js';
import useSignIn from './src/hooks/useSignIn.js';
import useUserProfile from './src/hooks/useUserProfile.js';
import useDelete from './src/hooks/useDelete.js';
import AutoComplete from './src/components/AutoComplete.js';
import useEditableSearchGrid from './src/hooks/useEditableSearchGrid.js';
export {
AddressUtility,
AutoComplete,
BackButton,
Breadcrumbs,
CheckboxWithLabel,
Expand Down Expand Up @@ -82,6 +85,7 @@ export {
SnackbarMessage,
usePreviousNextNavigation,
useDebounceValue,
useEditableSearchGrid,
useGet,
useSearch,
useInitialise,
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@linn-it/linn-form-components-library",
"version": "34.4.0",
"version": "34.5.0",
"private": false,
"repository": {
"type": "git",
Expand All @@ -22,7 +22,7 @@
"react-dropzone": "15.0.0"
},
"overrides": {
"fast-uri": "^3.1.7"
"fast-uri": "^3.1.8"
},
"scripts": {
"start": "react-scripts start",
Expand Down Expand Up @@ -95,7 +95,7 @@
"jest": "30.5.1",
"jest-environment-jsdom": "30.5.1",
"jscodeshift": "17.4.0",
"moment": "2.30.1",
"moment": "2.31.0",
"notistack": "3.0.2",
"postcss": "8.5.27",
"postcss-import": "17.0.0",
Expand Down
110 changes: 110 additions & 0 deletions src/components/AutoComplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import InputLabel from '@mui/material/InputLabel';
import TextField from '@mui/material/TextField';

function AutoComplete({
label,
options = [],
value = null,
onChange,
getOptionLabel,
isOptionEqualToValue,
matchesInput,
idField,
labelField,
required = false,
disabled = false,
disableClearable = false
}) {
const [inputValue, setInputValue] = useState(null);

// Convenience mode: when idField/labelField are supplied the three option callbacks are derived
// from them, and `value`/`onChange` speak the plain id (not the option object) — so a caller passing
// a coded list just gives the field names and works in ids. Omit them for the original object-mode
// API where the caller supplies the callbacks and the value/onChange are the whole option object.
const resolvedGetOptionLabel =
getOptionLabel ??
(labelField
? option => option?.[labelField] ?? (idField ? String(option[idField]) : '')
: option => `${option ?? ''}`);

const resolvedIsOptionEqualToValue =
isOptionEqualToValue ??
(idField ? (option, selected) => option?.[idField] === selected?.[idField] : undefined);

const resolvedMatchesInput =
matchesInput ??
(idField ? (option, typed) => String(option?.[idField]) === typed : undefined);

// In id mode, resolve the incoming id to its option object for display and emit the id back out.
const selectedOption = idField
? (options.find(option => option?.[idField] === value) ?? null)
: value;
const emitChange = idField ? option => onChange(option?.[idField] ?? null) : onChange;

const optionLabel = option => resolvedGetOptionLabel(option);

const handleBlur = () => {
const typedValue = inputValue?.trim();
if (typedValue) {
const matchingOption = options.find(
option =>
optionLabel(option).toUpperCase() === typedValue.toUpperCase() ||
resolvedMatchesInput?.(option, typedValue)
);
if (matchingOption) emitChange(matchingOption);
}
setInputValue(null);
};

return (
<Autocomplete
size="small"
autoHighlight
disableClearable={disableClearable}
options={options}
value={selectedOption}
inputValue={inputValue ?? (selectedOption ? optionLabel(selectedOption) : '')}
getOptionLabel={optionLabel}
isOptionEqualToValue={resolvedIsOptionEqualToValue}
onChange={(_, option) => {
emitChange(option);
setInputValue(null);
}}
onInputChange={(_, newValue, reason) => {
if (reason === 'input') setInputValue(newValue);
if (reason === 'clear') setInputValue('');
}}
onBlur={handleBlur}
disabled={disabled}
renderInput={params => (
<>
<InputLabel
required={required}
sx={{
fontSize: theme => theme.typography.fontSize,
color: 'inherit',
'& .MuiInputLabel-asterisk': {
color: theme => theme.palette.error.main
}
}}
htmlFor={params.id}
>
{label}
</InputLabel>
<TextField
{...params}
sx={{ paddingTop: 0, marginTop: theme => theme.spacing(1) }}
margin="dense"
size="small"
required={required}
variant="outlined"
/>
</>
)}
/>
);
}

export default AutoComplete;
127 changes: 127 additions & 0 deletions src/components/AutoComplete.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useArgs } from 'storybook/preview-api';
import AutoComplete from './AutoComplete';

const departments = [
{ departmentCode: 2508, description: 'Assets' },
{ departmentCode: 3100, description: 'Marketing' },
{ departmentCode: 4200, description: 'Research & Development' },
{ departmentCode: 5000, description: 'Operations' }
];

function StatefulAutoComplete(args) {
const [{ value }, updateArgs] = useArgs();
return (
<AutoComplete
{...args}
value={value}
onChange={newValue => updateArgs({ value: newValue })}
/>
);
}

const description = `
A reusable type-ahead select (wrapper over MUI \`Autocomplete\`). Two modes: **object mode** (you supply
the callbacks and work in option objects) and **id/label mode** (you supply two field names and work in
plain ids).

UX: type to filter, and **type a value then Tab/blur to commit the match** (matched by option label,
case-insensitive, or by \`matchesInput\` / the \`idField\` value).

### Id/label mode (give two field names, value is the id)

Use for a plain coded list. No callbacks, no \`find\` — \`value\`/\`onChange\` speak the id:

\`\`\`jsx
// departments: [{ departmentCode: 2508, description: 'Assets' }, ...]
<AutoComplete
label="Department"
options={departments}
idField="departmentCode" // the id property
labelField="description" // the text property
value={departmentCode} // a plain id (or null)
onChange={setDepartmentCode} // receives a plain id (or null)
/>
\`\`\`

The mode derives \`getOptionLabel\`, \`isOptionEqualToValue\`, \`matchesInput\`, and value resolution for
you. You can still override any derived callback by passing it explicitly.

### Object mode (supply the callbacks, value is the object)

Use when you need full control — e.g. the label is computed or equality spans more than one field:

\`\`\`jsx
<AutoComplete
label="Start Period"
options={startPeriodOptions} // array of period objects
value={selectedStartPeriod} // the selected OBJECT (or null)
getOptionLabel={option => option.monthName ?? String(option.periodNumber)}
isOptionEqualToValue={(option, value) => option.periodNumber === value.periodNumber}
matchesInput={(period, typed) => String(period.periodNumber) === typed}
onChange={period => setField('startPeriod', period?.periodNumber ?? null)}
required
/>
\`\`\`

> Rule of thumb: pass \`idField\` + \`labelField\` for a simple coded list and work in ids. Only drop to
> object mode when the label/equality/match logic is non-trivial (computed label, multi-field match).
`;

export default {
title: 'Components/AutoComplete',
component: AutoComplete,
tags: ['autodocs'],
parameters: {
docs: { description: { component: description } }
},
render: StatefulAutoComplete,
args: {
label: 'Department',
options: departments,
idField: 'departmentCode',
labelField: 'description',
value: null,
required: false,
disabled: false,
disableClearable: false
}
};

export const IdLabelMode = {
name: 'Id/label mode'
};

export const Preselected = {
args: {
value: 3100
}
};

export const Required = {
args: {
required: true
}
};

export const Disabled = {
args: {
value: 2508,
disabled: true
}
};

export const ObjectMode = {
name: 'Object mode',
render: StatefulAutoComplete,
args: {
label: 'Department',
options: departments,
idField: undefined,
labelField: undefined,
value: null,
getOptionLabel: option => option?.description ?? '',
isOptionEqualToValue: (option, selected) =>
option.departmentCode === selected?.departmentCode,
matchesInput: (option, typed) => String(option.departmentCode) === typed
}
};
Loading
Loading