mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-27 20:22:33 +08:00
[ui][typeahead] implementation
This commit is contained in:
@ -25,11 +25,11 @@ export default function ProductNavigation({ items, title }: Props) {
|
|||||||
{items.map((item) =>
|
{items.map((item) =>
|
||||||
item.children != null && item.children.length > 0 ? (
|
item.children != null && item.children.length > 0 ? (
|
||||||
<Menu key={item.name} as="div" className="relative text-left">
|
<Menu key={item.name} as="div" className="relative text-left">
|
||||||
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
|
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="ml-1 h-5 w-5 text-gray-500"
|
className="ml-1 h-5 w-5 text-slate-500"
|
||||||
/>
|
/>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Transition
|
<Transition
|
||||||
@ -47,8 +47,8 @@ export default function ProductNavigation({ items, title }: Props) {
|
|||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<Link
|
<Link
|
||||||
className={clsx(
|
className={clsx(
|
||||||
active ? 'bg-gray-100' : '',
|
active ? 'bg-slate-100' : '',
|
||||||
'block px-4 py-2 text-sm text-gray-700',
|
'block px-4 py-2 text-sm text-slate-700',
|
||||||
)}
|
)}
|
||||||
href={child.href}>
|
href={child.href}>
|
||||||
{child.name}
|
{child.name}
|
||||||
@ -63,7 +63,7 @@ export default function ProductNavigation({ items, title }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className="hover:text-primary-600 text-sm font-medium text-gray-900"
|
className="hover:text-primary-600 text-sm font-medium text-slate-900"
|
||||||
href={item.href}>
|
href={item.href}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
76
apps/storybook/stories/typeahead.stories.tsx
Normal file
76
apps/storybook/stories/typeahead.stories.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { ComponentMeta } from '@storybook/react';
|
||||||
|
import type { TypeaheadOption } from '@tih/ui';
|
||||||
|
import { Typeahead } from '@tih/ui';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
argTypes: {
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
isLabelHidden: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
control: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
component: Typeahead,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
iframeHeight: 400,
|
||||||
|
inlineStories: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: 'Typeahead',
|
||||||
|
} as ComponentMeta<typeof Typeahead>;
|
||||||
|
|
||||||
|
export function Basic({
|
||||||
|
disabled,
|
||||||
|
isLabelHidden,
|
||||||
|
label,
|
||||||
|
}: Pick<
|
||||||
|
React.ComponentProps<typeof Typeahead>,
|
||||||
|
'disabled' | 'isLabelHidden' | 'label'
|
||||||
|
>) {
|
||||||
|
const people = [
|
||||||
|
{ id: '1', label: 'Wade Cooper', value: '1' },
|
||||||
|
{ id: '2', label: 'Arlene Mccoy', value: '2' },
|
||||||
|
{ id: '3', label: 'Devon Webb', value: '3' },
|
||||||
|
{ id: '4', label: 'Tom Cook', value: '4' },
|
||||||
|
{ id: '5', label: 'Tanya Fox', value: '5' },
|
||||||
|
{ id: '6', label: 'Hellen Schmidt', value: '6' },
|
||||||
|
];
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<TypeaheadOption>(
|
||||||
|
people[0],
|
||||||
|
);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const filteredPeople =
|
||||||
|
query === ''
|
||||||
|
? people
|
||||||
|
: people.filter((person) =>
|
||||||
|
person.label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.includes(query.toLowerCase().replace(/\s+/g, '')),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typeahead
|
||||||
|
disabled={disabled}
|
||||||
|
isLabelHidden={isLabelHidden}
|
||||||
|
label={label}
|
||||||
|
options={filteredPeople}
|
||||||
|
selectedOption={selectedEntry}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onSelectOption={setSelectedEntry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Basic.args = {
|
||||||
|
disabled: false,
|
||||||
|
isLabelHidden: false,
|
||||||
|
label: 'Author',
|
||||||
|
};
|
@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
|
|||||||
return (
|
return (
|
||||||
<hr
|
<hr
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className={clsx('my-2 h-0 border-t border-slate-200', className)}
|
className={clsx('my-2 h-0 border-t border-slate-100', className)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
109
packages/ui/src/Typeahead/Typeahead.tsx
Normal file
109
packages/ui/src/Typeahead/Typeahead.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import { Combobox, Transition } from '@headlessui/react';
|
||||||
|
import { ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
||||||
|
|
||||||
|
export type TypeaheadOption = Readonly<{
|
||||||
|
// String value to uniquely identify the option.
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type Props = Readonly<{
|
||||||
|
disabled?: boolean;
|
||||||
|
isLabelHidden?: boolean;
|
||||||
|
label: string;
|
||||||
|
onQueryChange: (
|
||||||
|
value: string,
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => void;
|
||||||
|
onSelectOption: (option: TypeaheadOption) => void;
|
||||||
|
options: ReadonlyArray<TypeaheadOption>;
|
||||||
|
selectedOption: TypeaheadOption;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function Typeahead({
|
||||||
|
disabled = false,
|
||||||
|
isLabelHidden,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
onQueryChange,
|
||||||
|
selectedOption,
|
||||||
|
onSelectOption,
|
||||||
|
}: Props) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
disabled={disabled}
|
||||||
|
value={selectedOption}
|
||||||
|
onChange={onSelectOption}>
|
||||||
|
<Combobox.Label
|
||||||
|
className={clsx(
|
||||||
|
isLabelHidden
|
||||||
|
? 'sr-only'
|
||||||
|
: 'mb-1 block text-sm font-medium text-slate-700',
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</Combobox.Label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
|
||||||
|
<Combobox.Input
|
||||||
|
className={clsx(
|
||||||
|
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0',
|
||||||
|
disabled && 'pointer-events-none select-none bg-slate-100',
|
||||||
|
)}
|
||||||
|
displayValue={(option) =>
|
||||||
|
(option as unknown as TypeaheadOption).label
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
!disabled && onQueryChange(event.target.value, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-5 w-5 text-slate-400"
|
||||||
|
/>
|
||||||
|
</Combobox.Button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
afterLeave={() => setQuery('')}
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
{options.length === 0 && query !== '' ? (
|
||||||
|
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
|
||||||
|
Nothing found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={option.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
clsx(
|
||||||
|
'relative cursor-default select-none py-2 px-4 text-slate-500',
|
||||||
|
active && 'bg-slate-100',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={option}>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'block truncate',
|
||||||
|
selected ? 'font-medium' : 'font-normal',
|
||||||
|
)}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
}
|
@ -49,3 +49,6 @@ export { default as TextArea } from './TextArea/TextArea';
|
|||||||
// TextInput
|
// TextInput
|
||||||
export * from './TextInput/TextInput';
|
export * from './TextInput/TextInput';
|
||||||
export { default as TextInput } from './TextInput/TextInput';
|
export { default as TextInput } from './TextInput/TextInput';
|
||||||
|
// Typeahead
|
||||||
|
export * from './Typeahead/Typeahead';
|
||||||
|
export { default as Typeahead } from './Typeahead/Typeahead';
|
||||||
|
Reference in New Issue
Block a user