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
151 changes: 87 additions & 64 deletions src/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition';
import { inputVariants } from '../Input';

export interface AutocompleteProps<T> extends Pick<
Expand Down Expand Up @@ -120,15 +122,7 @@ function Autocomplete<T>({
const containerRef = React.useRef<HTMLDivElement>(null);
const listId = React.useId();

useClickOutside(containerRef, () => setOpen(false), open);

const setQuery = React.useCallback(
(next: string) => {
if (!isControlled) setUncontrolledValue(next);
onValueChange?.(next);
},
[isControlled, onValueChange]
);
const meetsMinLength = query.length >= minQueryLength;

const filteredItems = React.useMemo(() => {
if (!filter) return items;
Expand All @@ -149,11 +143,31 @@ function Autocomplete<T>({
return itemRows;
}, [filteredItems, showCreate, getItemKey]);

const meetsMinLength = query.length >= minQueryLength;
const hasContent =
rows.length > 0 || (meetsMinLength && emptyMessage != null);
const isOpen = open && meetsMinLength && hasContent;

// Portal + fixed positioning so the listbox escapes overflow-hidden
// ancestors (cards, dialogs, scroll containers, …).
const { anchorRef, floatingRef, style } = useAnchoredPosition<
HTMLDivElement,
HTMLDivElement
>({ open: isOpen, matchWidth: true, maxHeight: 300 });

const outsideRefs = React.useMemo(
() => [containerRef, floatingRef],
[floatingRef]
);
useClickOutside(outsideRefs, () => setOpen(false), open);
Comment thread
garrity-miepub marked this conversation as resolved.

const setQuery = React.useCallback(
(next: string) => {
if (!isControlled) setUncontrolledValue(next);
onValueChange?.(next);
},
[isControlled, onValueChange]
);

React.useEffect(() => {
if (!isOpen) setActiveIndex(-1);
}, [isOpen]);
Expand Down Expand Up @@ -207,7 +221,10 @@ function Autocomplete<T>({

return (
<div
ref={containerRef}
ref={(node) => {
containerRef.current = node;
anchorRef.current = node;
}}
data-slot="autocomplete"
className={cn('relative', className)}
>
Expand All @@ -234,60 +251,66 @@ function Autocomplete<T>({
{...inputProps}
/>

{isOpen && (
<div
id={listId}
role="listbox"
data-slot="autocomplete-list"
className={cn(
'border-border absolute z-50 mt-1 w-full overflow-auto rounded-md border',
'bg-card text-card-foreground max-h-[300px] shadow-lg'
)}
>
{rows.length === 0
? emptyMessage != null && (
<div
data-slot="autocomplete-empty"
className="text-muted-foreground px-4 py-3 text-sm"
>
{emptyMessage}
</div>
)
: rows.map((row, index) => {
const isActive = index === activeIndex;
const optionId = `${listId}-opt-${row.key}`;
return (
<button
key={row.key}
id={optionId}
type="button"
role="option"
aria-selected={isActive}
data-slot={
row.kind === 'create'
? 'autocomplete-create'
: 'autocomplete-option'
}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commitRow(row)}
className={cn(
'w-full px-4 py-3 text-left text-sm transition-colors',
'border-border border-b last:border-b-0',
'focus:outline-none',
isActive ? 'bg-muted text-foreground' : 'hover:bg-muted',
row.kind === 'create' &&
'text-primary-800 flex items-center gap-2 font-medium'
)}
{isOpen &&
createPortal(
<div
ref={floatingRef}
style={style}
id={listId}
role="listbox"
data-slot="autocomplete-list"
className={cn(
'border-border overflow-auto rounded-md border',
'bg-card text-card-foreground shadow-lg'
)}
>
{rows.length === 0
? emptyMessage != null && (
<div
data-slot="autocomplete-empty"
className="text-muted-foreground px-4 py-3 text-sm"
>
{row.kind === 'create'
? createLabel!(query)
: renderItem(row.item)}
</button>
);
})}
</div>
)}
{emptyMessage}
</div>
)
: rows.map((row, index) => {
const isActive = index === activeIndex;
const optionId = `${listId}-opt-${row.key}`;
return (
<button
key={row.key}
id={optionId}
type="button"
role="option"
aria-selected={isActive}
data-slot={
row.kind === 'create'
? 'autocomplete-create'
: 'autocomplete-option'
}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commitRow(row)}
className={cn(
'w-full px-4 py-3 text-left text-sm transition-colors',
'border-border border-b last:border-b-0',
'focus:outline-none',
isActive
? 'bg-muted text-foreground'
: 'hover:bg-muted',
row.kind === 'create' &&
'text-primary-800 flex items-center gap-2 font-medium'
)}
>
{row.kind === 'create'
? createLabel!(query)
: renderItem(row.item)}
</button>
);
})}
</div>,
document.body
)}
</div>
);
}
Expand Down
90 changes: 50 additions & 40 deletions src/components/BookingDialog/BookingDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition';
import { useClickOutside } from '../../hooks/useClickOutside';
import { isStorybookDocsMode } from '../../utils/environment';

// =============================================================================
Expand Down Expand Up @@ -151,18 +154,18 @@ export function ServiceSelect({
const [isOpen, setIsOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Portal + fixed positioning so the dropdown escapes overflow-hidden
// ancestors (the dialog itself scrolls/clips).
const { anchorRef, floatingRef, style } = useAnchoredPosition<
HTMLDivElement,
HTMLDivElement
>({ open: isOpen, matchWidth: true, maxHeight: 240 });

const outsideRefs = React.useMemo(
() => [dropdownRef, floatingRef],
[floatingRef]
);
useClickOutside(outsideRefs, () => setIsOpen(false), isOpen);
Comment thread
garrity-miepub marked this conversation as resolved.

const toggleService = (serviceSlug: string) => {
if (selectedServices.includes(serviceSlug)) {
Expand All @@ -178,7 +181,10 @@ export function ServiceSelect({

return (
<div
ref={dropdownRef}
ref={(node) => {
dropdownRef.current = node;
anchorRef.current = node;
}}
className={cn('relative', className)}
data-slot="service-select"
>
Expand Down Expand Up @@ -218,33 +224,37 @@ export function ServiceSelect({
/>
</button>

{isOpen && (
<div
className="border-border bg-card absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
data-slot="service-select-dropdown"
>
{services.map((service) => (
<label
key={service.slug}
data-slot="service-select-option"
className="hover:bg-muted flex cursor-pointer items-center gap-3 px-4 py-3"
>
<input
type="checkbox"
checked={selectedServices.includes(service.slug)}
onChange={() => toggleService(service.slug)}
className="text-primary-800 focus:ring-primary-500 border-input h-4 w-4 rounded"
/>
<span className="text-foreground">{service.name}</span>
</label>
))}
{services.length === 0 && (
<div className="text-muted-foreground px-4 py-3 text-center">
No services available
</div>
)}
</div>
)}
{isOpen &&
createPortal(
<div
ref={floatingRef}
style={style}
className="border-border bg-card overflow-auto rounded-lg border shadow-lg"
data-slot="service-select-dropdown"
>
{services.map((service) => (
<label
key={service.slug}
data-slot="service-select-option"
className="hover:bg-muted flex cursor-pointer items-center gap-3 px-4 py-3"
>
<input
type="checkbox"
checked={selectedServices.includes(service.slug)}
onChange={() => toggleService(service.slug)}
className="text-primary-800 focus:ring-primary-500 border-input h-4 w-4 rounded"
/>
<span className="text-foreground">{service.name}</span>
</label>
))}
{services.length === 0 && (
<div className="text-muted-foreground px-4 py-3 text-center">
No services available
</div>
)}
</div>,
document.body
)}

{error && (
<p
Expand Down
53 changes: 26 additions & 27 deletions src/components/BusinessHoursEditor/BusinessHoursEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';
import { useCallback } from 'react';
import { Button } from '../Button/Button';
import { Input } from '../Input/Input';
import { Dropdown, DropdownItem } from '../Dropdown';
import { cn } from '../../utils/cn';

// ============================================================================
Expand Down Expand Up @@ -226,36 +227,34 @@ export function BusinessHoursEditor({
</h4>
<div className="flex items-center gap-2">
{hours.length > 0 && (
<div className="group relative">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-xs"
>
<CopyIcon className="mr-1 h-3 w-3" />
Copy
</Button>
<div className="invisible absolute top-full right-0 z-10 mt-1 rounded-md border border-gray-200 bg-white opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100 dark:border-gray-700 dark:bg-gray-800">
<button
<Dropdown
placement="bottom-end"
trigger={
<Button
type="button"
className="block w-full px-3 py-2 text-left text-xs whitespace-nowrap hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => handleCopyToAll(dayIndex)}
variant="ghost"
size="sm"
disabled={disabled}
className="text-xs"
>
Copy to all days
</button>
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs whitespace-nowrap hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => handleCopyToWeekdays(dayIndex)}
disabled={disabled}
>
Copy to weekdays
</button>
</div>
</div>
<CopyIcon className="mr-1 h-3 w-3" />
Copy
</Button>
}
>
<DropdownItem
onClick={() => handleCopyToAll(dayIndex)}
disabled={disabled}
>
Copy to all days
</DropdownItem>
<DropdownItem
onClick={() => handleCopyToWeekdays(dayIndex)}
disabled={disabled}
>
Copy to weekdays
</DropdownItem>
</Dropdown>
)}
<Button
type="button"
Expand Down
Loading
Loading