@adecore/ui 0.33.3 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Banner.d.ts +2 -1
- package/dist/Banner.js +92 -51
- package/dist/CopyButton.d.ts +24 -0
- package/dist/CopyButton.js +144 -0
- package/dist/ErrorBoundary.js +16 -18
- package/dist/ProjectSwitcher.js +66 -73
- package/dist/RelativeTime.d.ts +9 -0
- package/dist/RelativeTime.js +38 -0
- package/dist/Segmented.d.ts +4 -1
- package/dist/Segmented.js +111 -86
- package/dist/Toasts.js +1 -1
- package/dist/combobox/index.parts.d.ts +1 -0
- package/dist/combobox/index.parts.js +1 -0
- package/dist/combobox/parts.d.ts +32 -0
- package/dist/combobox/parts.js +556 -0
- package/dist/command-palette/index.parts.d.ts +2 -0
- package/dist/command-palette/index.parts.js +3 -0
- package/dist/command-palette/parts.d.ts +32 -0
- package/dist/command-palette/parts.js +422 -0
- package/dist/format/datetime.d.ts +1 -0
- package/dist/format/datetime.js +8 -0
- package/dist/format/duration.d.ts +1 -0
- package/dist/format/duration.js +24 -1
- package/dist/format/index.d.ts +2 -2
- package/dist/format/index.js +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/locales/en.d.ts +8 -0
- package/dist/locales/en.js +8 -0
- package/dist/locales/nl.d.ts +8 -0
- package/dist/locales/nl.js +8 -0
- package/dist/match.d.ts +1 -0
- package/dist/match.js +47 -0
- package/dist/segmented-keys.d.ts +3 -0
- package/dist/segmented-keys.js +33 -0
- package/dist/theme.css +15 -0
- package/dist/toast-store.d.ts +1 -0
- package/dist/toast-store.js +16 -6
- package/dist/workspace/TabStrip.d.ts +2 -1
- package/dist/workspace/TabStrip.js +432 -372
- package/package.json +1 -1
- package/src/Banner.tsx +33 -9
- package/src/CopyButton.tsx +86 -0
- package/src/ErrorBoundary.tsx +3 -4
- package/src/ProjectSwitcher.tsx +4 -2
- package/src/RelativeTime.tsx +44 -0
- package/src/Segmented.tsx +60 -33
- package/src/Toasts.tsx +1 -1
- package/src/combobox/index.parts.ts +12 -0
- package/src/combobox/parts.tsx +147 -0
- package/src/command-palette/index.parts.ts +16 -0
- package/src/command-palette/parts.tsx +145 -0
- package/src/format/datetime.ts +5 -0
- package/src/format/duration.ts +25 -1
- package/src/format/index.ts +2 -1
- package/src/index.ts +7 -0
- package/src/locales/en.ts +8 -0
- package/src/locales/nl.ts +8 -0
- package/src/match.ts +50 -0
- package/src/segmented-keys.ts +33 -0
- package/src/theme.css +15 -0
- package/src/toast-store.ts +16 -6
- package/src/workspace/TabStrip.tsx +31 -2
package/package.json
CHANGED
package/src/Banner.tsx
CHANGED
|
@@ -13,13 +13,20 @@ const TONE: Record<BannerTone, string> = {
|
|
|
13
13
|
neutral: 'text-text-muted'
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
const OVERLAY = 'absolute inset-x-0 top-3 z-20';
|
|
17
|
+
|
|
16
18
|
export interface BannerProps {
|
|
17
19
|
icon: LucideIcon;
|
|
18
20
|
tone: BannerTone;
|
|
19
21
|
message: ReactNode;
|
|
20
22
|
/* The buttons that answer the line. */
|
|
21
23
|
children?: ReactNode;
|
|
22
|
-
/*
|
|
24
|
+
/*
|
|
25
|
+
* `overlay` floats the glass strip centered along the top of the positioned parent; `inline` stands
|
|
26
|
+
* in the flow and fills its row. Under `overlay` a `className` replaces the overlay position instead
|
|
27
|
+
* of adding to it, so its utilities never meet the component's own.
|
|
28
|
+
*/
|
|
29
|
+
placement?: 'overlay' | 'inline';
|
|
23
30
|
className?: string;
|
|
24
31
|
ref?: Ref<HTMLDivElement>;
|
|
25
32
|
}
|
|
@@ -28,16 +35,33 @@ export interface BannerProps {
|
|
|
28
35
|
* The strip over a view: one line and the buttons that answer it. It is where anything that waits
|
|
29
36
|
* for a person goes (a conflict, a failure, a request), because a corner toast is read after the fact
|
|
30
37
|
* and a decision has to be where the eyes already are. One at a time: the slot is one row and the
|
|
31
|
-
* caller picks what stands in it.
|
|
38
|
+
* caller picks what stands in it. Inline, it is a notice that belongs to the page it stands in.
|
|
32
39
|
*/
|
|
33
|
-
export function Banner({ icon, tone, message, children,
|
|
40
|
+
export function Banner({ icon, tone, message, children, placement = 'overlay', className, ref }: BannerProps) {
|
|
41
|
+
const line = (
|
|
42
|
+
<>
|
|
43
|
+
<Icon icon={icon} size={16} className={clsx('shrink-0', TONE[tone])} />
|
|
44
|
+
<span className="grow">{message}</span>
|
|
45
|
+
{children}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (placement === 'inline') {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={ref}
|
|
53
|
+
className={clsx('flex items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text', className)}
|
|
54
|
+
role="status"
|
|
55
|
+
aria-live="polite"
|
|
56
|
+
>
|
|
57
|
+
{line}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
return (
|
|
35
|
-
<div ref={ref} className={clsx('pointer-events-auto flex justify-center px-4', className)} role="status" aria-live="polite">
|
|
36
|
-
<Surface className="flex max-w-[640px] items-center gap-3 rounded-lg px-3 py-2 text-sm text-text">
|
|
37
|
-
<Icon icon={icon} size={16} className={clsx('shrink-0', TONE[tone])} />
|
|
38
|
-
<span className="grow">{message}</span>
|
|
39
|
-
{children}
|
|
40
|
-
</Surface>
|
|
63
|
+
<div ref={ref} className={clsx('pointer-events-auto flex justify-center px-4', className ?? OVERLAY)} role="status" aria-live="polite">
|
|
64
|
+
<Surface className="flex max-w-[640px] items-center gap-3 rounded-lg px-3 py-2 text-sm text-text">{line}</Surface>
|
|
41
65
|
</div>
|
|
42
66
|
);
|
|
43
67
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useState, type Ref } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Check, Copy } from 'lucide-react';
|
|
4
|
+
import { Button, type ButtonSize, type ButtonVariant } from './Button.tsx';
|
|
5
|
+
import { copyText } from './clipboard.ts';
|
|
6
|
+
import { Icon } from './Icon.tsx';
|
|
7
|
+
import { IconButton, type IconButtonSize } from './IconButton.tsx';
|
|
8
|
+
import type { TooltipSide } from './Tooltip.tsx';
|
|
9
|
+
|
|
10
|
+
const COPIED_MS = 1500;
|
|
11
|
+
|
|
12
|
+
interface CopyButtonBase {
|
|
13
|
+
text: string;
|
|
14
|
+
/* The accessible name and the tooltip of the icon, or the word on the button with `showLabel`. */
|
|
15
|
+
label?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
ref?: Ref<HTMLButtonElement>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CopyIconButtonProps extends CopyButtonBase {
|
|
22
|
+
showLabel?: false;
|
|
23
|
+
size?: IconButtonSize;
|
|
24
|
+
tooltipSide?: TooltipSide;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CopyLabelButtonProps extends CopyButtonBase {
|
|
28
|
+
showLabel: true;
|
|
29
|
+
size?: ButtonSize;
|
|
30
|
+
variant?: ButtonVariant;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CopyButtonProps = CopyIconButtonProps | CopyLabelButtonProps;
|
|
34
|
+
|
|
35
|
+
/* A button that puts text on the clipboard and shows a check and "Copied" for a moment once it got there. */
|
|
36
|
+
export function CopyButton(props: CopyButtonProps) {
|
|
37
|
+
const { text, label, disabled, className, ref } = props;
|
|
38
|
+
const { t } = useTranslation('ui');
|
|
39
|
+
const [copiedAt, setCopiedAt] = useState<number | null>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (copiedAt === null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const timer = window.setTimeout(() => setCopiedAt(null), COPIED_MS);
|
|
46
|
+
return () => window.clearTimeout(timer);
|
|
47
|
+
}, [copiedAt]);
|
|
48
|
+
|
|
49
|
+
const copied = copiedAt !== null;
|
|
50
|
+
const name = label ?? t('action.copy');
|
|
51
|
+
const copy = async (): Promise<void> => {
|
|
52
|
+
// A refusal goes unconfirmed and unreported: a person can do nothing about it (`clipboard.ts`).
|
|
53
|
+
if (await copyText(text)) {
|
|
54
|
+
setCopiedAt(Date.now());
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const button = props.showLabel ? (
|
|
59
|
+
<Button ref={ref} variant={props.variant} size={props.size} disabled={disabled} className={className} onClick={() => void copy()}>
|
|
60
|
+
<Icon icon={copied ? Check : Copy} size={props.size === 'xs' ? 12 : 14} />
|
|
61
|
+
{copied ? t('action.copied') : name}
|
|
62
|
+
</Button>
|
|
63
|
+
) : (
|
|
64
|
+
<IconButton
|
|
65
|
+
ref={ref}
|
|
66
|
+
icon={copied ? Check : Copy}
|
|
67
|
+
label={name}
|
|
68
|
+
tooltip={copied ? t('action.copied') : name}
|
|
69
|
+
tooltipSide={props.tooltipSide}
|
|
70
|
+
size={props.size}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
className={className}
|
|
73
|
+
onClick={() => void copy()}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{button}
|
|
79
|
+
{/* The check is only drawn, and a focused button's new name is not always read out. A new node
|
|
80
|
+
per copy, as a region whose text stays the same says nothing for the second one. */}
|
|
81
|
+
<span role="status" className="sr-only">
|
|
82
|
+
<span key={copiedAt}>{copied ? t('action.copied') : ''}</span>
|
|
83
|
+
</span>
|
|
84
|
+
</>
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/ErrorBoundary.tsx
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import {
|
|
3
|
+
import { RotateCw, TriangleAlert } from 'lucide-react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { Button } from './Button.tsx';
|
|
6
6
|
import { ButtonGroup } from './ButtonGroup.tsx';
|
|
7
|
-
import {
|
|
7
|
+
import { CopyButton } from './CopyButton.tsx';
|
|
8
8
|
import { errorMessageOf, errorReport, shouldReset, type ResetKeys } from './error-boundary.ts';
|
|
9
9
|
import { Icon } from './Icon.tsx';
|
|
10
|
-
import { IconButton } from './IconButton.tsx';
|
|
11
10
|
|
|
12
11
|
export interface ErrorBoundaryProps {
|
|
13
12
|
/* What failed, as the first line of the message: "Could not show this view". */
|
|
@@ -61,7 +60,7 @@ function ErrorFallback({ label, error, componentStack, compact, reload, classNam
|
|
|
61
60
|
</Button>
|
|
62
61
|
)}
|
|
63
62
|
<ButtonGroup>
|
|
64
|
-
<
|
|
63
|
+
<CopyButton text={errorReport(label, error, componentStack)} label={t('action.copyError')} size="sm" />
|
|
65
64
|
</ButtonGroup>
|
|
66
65
|
</div>
|
|
67
66
|
</div>
|
package/src/ProjectSwitcher.tsx
CHANGED
|
@@ -32,6 +32,7 @@ export interface ProjectSwitcherProps<Item extends ProjectSwitcherItem = Project
|
|
|
32
32
|
before?: ReactNode;
|
|
33
33
|
/* Menu items in one group with Recent projects, such as opening a folder or creating a project. */
|
|
34
34
|
children?: ReactNode;
|
|
35
|
+
/* What the trigger reads while `current` is null; next to a current project, it comes before that name in the accessible name. */
|
|
35
36
|
label?: string;
|
|
36
37
|
disabled?: boolean;
|
|
37
38
|
open?: boolean;
|
|
@@ -58,18 +59,19 @@ export function ProjectSwitcher<Item extends ProjectSwitcherItem>({
|
|
|
58
59
|
ref
|
|
59
60
|
}: ProjectSwitcherProps<Item>) {
|
|
60
61
|
const { t } = useTranslation('ui');
|
|
62
|
+
const empty = label ?? t('projectSwitcher.label');
|
|
61
63
|
|
|
62
64
|
return (
|
|
63
65
|
<Menu.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
|
64
66
|
<Menu.Trigger
|
|
65
67
|
ref={ref}
|
|
66
68
|
disabled={disabled}
|
|
67
|
-
aria-label={label
|
|
69
|
+
aria-label={current === null ? empty : label === undefined ? current.name : `${label}: ${current.name}`}
|
|
68
70
|
className={clsx('project-switcher-trigger', className)}
|
|
69
71
|
>
|
|
70
72
|
{leading}
|
|
71
73
|
{current?.icon}
|
|
72
|
-
<span className="truncate text-sm font-medium text-text">{current?.name ??
|
|
74
|
+
<span className="truncate text-sm font-medium text-text">{current?.name ?? empty}</span>
|
|
73
75
|
<Icon icon={ChevronDown} size={14} className="shrink-0 text-text-muted" />
|
|
74
76
|
</Menu.Trigger>
|
|
75
77
|
<Menu.Popup className="min-w-60">
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { formatAge, formatDayClockWithYear, useFormatLocale } from './format/index.ts';
|
|
4
|
+
import { Tooltip, type TooltipSide } from './Tooltip.tsx';
|
|
5
|
+
import { useNow } from './useNow.ts';
|
|
6
|
+
|
|
7
|
+
/* The finest step of an age is a minute, so half of one keeps it at most thirty seconds behind. */
|
|
8
|
+
const TICK_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export interface RelativeTimeProps {
|
|
11
|
+
/* `null`, `undefined` or an invalid date for a moment that is not known, drawn as a dash. */
|
|
12
|
+
at: Date | number | null | undefined;
|
|
13
|
+
tooltipSide?: TooltipSide;
|
|
14
|
+
className?: string;
|
|
15
|
+
ref?: Ref<HTMLElement>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A moment as its age, `3m ago`, counting on while it is on screen, with the exact time on hover and on keyboard focus. */
|
|
19
|
+
export function RelativeTime({ at, tooltipSide, className, ref }: RelativeTimeProps) {
|
|
20
|
+
// The formatters read the locale from a module, not from an input, so a compiled render would keep its first words.
|
|
21
|
+
'use no memo';
|
|
22
|
+
useFormatLocale();
|
|
23
|
+
const now = useNow(TICK_MS);
|
|
24
|
+
if (at === null || at === undefined || Number.isNaN(new Date(at).getTime())) {
|
|
25
|
+
return (
|
|
26
|
+
<span ref={ref} className={className}>
|
|
27
|
+
-
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
<Tooltip label={formatDayClockWithYear(at)} side={tooltipSide}>
|
|
33
|
+
{/* Text is no tab stop of its own, and a hint that only the pointer can open is lost to the keyboard. */}
|
|
34
|
+
<time
|
|
35
|
+
ref={ref as Ref<HTMLTimeElement>}
|
|
36
|
+
dateTime={new Date(at).toISOString()}
|
|
37
|
+
tabIndex={0}
|
|
38
|
+
className={clsx('focus-ring rounded-sm whitespace-nowrap', className)}
|
|
39
|
+
>
|
|
40
|
+
{formatAge(at, now)}
|
|
41
|
+
</time>
|
|
42
|
+
</Tooltip>
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/Segmented.tsx
CHANGED
|
@@ -2,11 +2,15 @@ import type { KeyboardEvent, Ref } from 'react';
|
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import type { LucideIcon } from 'lucide-react';
|
|
4
4
|
import { Icon } from './Icon.tsx';
|
|
5
|
+
import { segmentedStop, segmentedTarget } from './segmented-keys.ts';
|
|
6
|
+
import { Tooltip } from './Tooltip.tsx';
|
|
5
7
|
|
|
6
8
|
export interface SegmentedOption<T extends string> {
|
|
7
9
|
id: T;
|
|
8
10
|
label: string;
|
|
9
11
|
icon?: LucideIcon;
|
|
12
|
+
/* Dims the option and takes it out of the tab order and the arrow keys; the rest of the group stays usable. */
|
|
13
|
+
disabled?: boolean;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export interface SegmentedProps<T extends string> {
|
|
@@ -18,30 +22,41 @@ export interface SegmentedProps<T extends string> {
|
|
|
18
22
|
disabled?: boolean;
|
|
19
23
|
/* `vertical` stacks the options in a column as wide as its container, for labels too long to stand side by side. */
|
|
20
24
|
orientation?: 'horizontal' | 'vertical';
|
|
25
|
+
/* 32 pixels high at `md` and 28 at `sm`, the heights of `Input`, `Select` and `Button`. */
|
|
26
|
+
size?: 'md' | 'sm';
|
|
27
|
+
/* Draws every option with an icon as a square, the label as its accessible name and its tooltip. One without an icon keeps its label. */
|
|
28
|
+
iconOnly?: boolean;
|
|
21
29
|
className?: string;
|
|
22
30
|
ref?: Ref<HTMLDivElement>;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
const
|
|
33
|
+
const SIZE = {
|
|
34
|
+
md: { track: 'rounded-lg', row: 'min-h-8', option: 'min-h-7 rounded-md px-3 py-1', square: 'size-7 rounded-md', icon: 14 },
|
|
35
|
+
sm: { track: 'rounded-md', row: 'min-h-7', option: 'min-h-6 rounded-sm px-2.5 py-0.5', square: 'size-6 rounded-sm', icon: 12 }
|
|
36
|
+
} as const;
|
|
26
37
|
|
|
27
38
|
/*
|
|
28
39
|
* One of a few options side by side in one sunken track, the picked one lifted out of it. A radio
|
|
29
40
|
* group: one tab stop, and the arrows pick and focus the next option, around the ends.
|
|
30
41
|
*/
|
|
31
|
-
export function Segmented<T extends string>({
|
|
42
|
+
export function Segmented<T extends string>({
|
|
43
|
+
value,
|
|
44
|
+
onValueChange,
|
|
45
|
+
options,
|
|
46
|
+
label,
|
|
47
|
+
disabled,
|
|
48
|
+
orientation = 'horizontal',
|
|
49
|
+
size = 'md',
|
|
50
|
+
iconOnly = false,
|
|
51
|
+
className,
|
|
52
|
+
ref
|
|
53
|
+
}: SegmentedProps<T>) {
|
|
32
54
|
const vertical = orientation === 'vertical';
|
|
33
|
-
const
|
|
34
|
-
const stop =
|
|
55
|
+
const sizing = SIZE[size];
|
|
56
|
+
const stop = segmentedStop(options, value);
|
|
35
57
|
|
|
36
58
|
function move(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
|
37
|
-
const next =
|
|
38
|
-
event.key in STEPS
|
|
39
|
-
? (index + STEPS[event.key]! + options.length) % options.length
|
|
40
|
-
: event.key === 'Home'
|
|
41
|
-
? 0
|
|
42
|
-
: event.key === 'End'
|
|
43
|
-
? options.length - 1
|
|
44
|
-
: null;
|
|
59
|
+
const next = segmentedTarget(options, index, event.key);
|
|
45
60
|
if (next === null) {
|
|
46
61
|
return;
|
|
47
62
|
}
|
|
@@ -54,33 +69,45 @@ export function Segmented<T extends string>({ value, onValueChange, options, lab
|
|
|
54
69
|
<div
|
|
55
70
|
ref={ref}
|
|
56
71
|
className={clsx(
|
|
57
|
-
'flex
|
|
58
|
-
|
|
72
|
+
'flex bg-surface-sunken p-0.5 text-xs font-medium',
|
|
73
|
+
sizing.track,
|
|
74
|
+
vertical ? 'w-full flex-col items-stretch gap-0.5' : clsx(sizing.row, 'w-fit items-center'),
|
|
59
75
|
className
|
|
60
76
|
)}
|
|
61
77
|
role="radiogroup"
|
|
62
78
|
aria-label={label}
|
|
63
79
|
aria-orientation={vertical ? 'vertical' : undefined}
|
|
64
80
|
>
|
|
65
|
-
{options.map((option, index) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
{options.map((option, index) => {
|
|
82
|
+
const square = iconOnly && option.icon !== undefined;
|
|
83
|
+
const button = (
|
|
84
|
+
<button
|
|
85
|
+
key={option.id}
|
|
86
|
+
type="button"
|
|
87
|
+
role="radio"
|
|
88
|
+
aria-checked={value === option.id}
|
|
89
|
+
tabIndex={index === stop ? 0 : -1}
|
|
90
|
+
disabled={disabled || option.disabled}
|
|
91
|
+
className={clsx(
|
|
92
|
+
'flex items-center transition-colors disabled:opacity-50',
|
|
93
|
+
square ? clsx(sizing.square, 'justify-center') : clsx(sizing.option, 'gap-1.5 text-left'),
|
|
94
|
+
value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted enabled:hover:text-text'
|
|
95
|
+
)}
|
|
96
|
+
onClick={() => onValueChange(option.id)}
|
|
97
|
+
onKeyDown={(event) => move(event, index)}
|
|
98
|
+
>
|
|
99
|
+
{option.icon && <Icon icon={option.icon} size={sizing.icon} className="shrink-0" />}
|
|
100
|
+
{!square && option.label}
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
return square ? (
|
|
104
|
+
<Tooltip key={option.id} label={option.label} name>
|
|
105
|
+
{button}
|
|
106
|
+
</Tooltip>
|
|
107
|
+
) : (
|
|
108
|
+
button
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
84
111
|
</div>
|
|
85
112
|
);
|
|
86
113
|
}
|
package/src/Toasts.tsx
CHANGED
|
@@ -70,7 +70,7 @@ function ActionButton({ action }: { action: ToastAction }) {
|
|
|
70
70
|
function ToastCard<T extends Toast>({ toast, footer, onDismiss }: { toast: T; footer?: (toast: T) => ReactNode; onDismiss(): void }) {
|
|
71
71
|
const { t } = useTranslation('ui');
|
|
72
72
|
// A toast that goes by itself keeps its close button out of sight until the pointer or the keyboard is on it.
|
|
73
|
-
const leaves = toast.
|
|
73
|
+
const leaves = toast.deadline !== undefined;
|
|
74
74
|
const actions = [...(toast.action === undefined ? [] : [toast.action]), ...(toast.actions ?? [])];
|
|
75
75
|
// An offer that runs out shows how long it has left instead.
|
|
76
76
|
const countdown = actions.length > 0 ? toast.deadline : undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ComboboxCollection as Collection,
|
|
3
|
+
ComboboxEmpty as Empty,
|
|
4
|
+
ComboboxGroup as Group,
|
|
5
|
+
ComboboxGroupLabel as GroupLabel,
|
|
6
|
+
ComboboxInput as Input,
|
|
7
|
+
ComboboxItem as Item,
|
|
8
|
+
ComboboxList as List,
|
|
9
|
+
ComboboxPopup as Popup,
|
|
10
|
+
ComboboxRoot as Root,
|
|
11
|
+
ComboboxStatus as Status
|
|
12
|
+
} from './parts.tsx';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import type { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
6
|
+
import { withClass } from '../class-name.ts';
|
|
7
|
+
import { fieldControlProps, useFieldControl } from '../field-context.ts';
|
|
8
|
+
import { Icon } from '../Icon.tsx';
|
|
9
|
+
import { Kbd } from '../Kbd.tsx';
|
|
10
|
+
import { MENU_LABEL } from '../menu/parts.tsx';
|
|
11
|
+
import { splitPlacement, type PlacementKey } from '../placement.ts';
|
|
12
|
+
import { renderPositioner } from '../popup-layer.ts';
|
|
13
|
+
import type { Shortcut } from '../shortcut.ts';
|
|
14
|
+
|
|
15
|
+
/* A line in the list instead of rows: nothing found, a search still running. It takes no room while it has nothing to say. */
|
|
16
|
+
const LIST_NOTE = 'text-center text-xs text-text-faint not-empty:px-3 not-empty:py-4';
|
|
17
|
+
|
|
18
|
+
/* The field keeps free text: Base UI's autocomplete, whose suggestions only offer text, rather than its combobox, which holds a value. */
|
|
19
|
+
export const ComboboxRoot = Autocomplete.Root;
|
|
20
|
+
|
|
21
|
+
export type ComboboxInputProps = Omit<ComponentProps<typeof Autocomplete.Input>, 'size' | 'className'> & {
|
|
22
|
+
className?: string;
|
|
23
|
+
/* The heights of `Input`. */
|
|
24
|
+
size?: 'md' | 'sm';
|
|
25
|
+
mono?: boolean;
|
|
26
|
+
/* Drawn in front of the text. `className` then goes to the box around the icon and the input; `ref` stays on the input. */
|
|
27
|
+
icon?: LucideIcon;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/* The field the suggestions open under, drawn as the library's `Input`. */
|
|
31
|
+
export function ComboboxInput({ size = 'md', mono = false, icon, className, id, ...props }: ComboboxInputProps) {
|
|
32
|
+
const field = useFieldControl();
|
|
33
|
+
const small = size === 'sm';
|
|
34
|
+
const control = { ...fieldControlProps(field, id), ...props };
|
|
35
|
+
|
|
36
|
+
if (icon === undefined) {
|
|
37
|
+
return <Autocomplete.Input {...control} className={clsx('field', small && 'field-sm', mono && 'font-mono text-code', className)} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The group is what the popup lines up with, so a list under a field with an icon is as wide as the whole box.
|
|
41
|
+
return (
|
|
42
|
+
<Autocomplete.InputGroup className={clsx('field relative flex p-0', small && 'field-sm', className)}>
|
|
43
|
+
<Icon
|
|
44
|
+
icon={icon}
|
|
45
|
+
size={small ? 12 : 14}
|
|
46
|
+
className={clsx('pointer-events-none absolute top-1/2 -translate-y-1/2 text-text-faint', small ? 'left-2' : 'left-2.5')}
|
|
47
|
+
/>
|
|
48
|
+
<Autocomplete.Input
|
|
49
|
+
{...control}
|
|
50
|
+
className={clsx(
|
|
51
|
+
'min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint',
|
|
52
|
+
small ? 'pr-2 pl-7' : 'pr-2.5 pl-8',
|
|
53
|
+
mono && 'font-mono text-code'
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
</Autocomplete.InputGroup>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Placement = Pick<ComponentProps<typeof Autocomplete.Positioner>, PlacementKey>;
|
|
61
|
+
|
|
62
|
+
export type ComboboxPopupProps = ComponentProps<typeof Autocomplete.Popup> & Placement;
|
|
63
|
+
|
|
64
|
+
/* The portal, the positioner and the menu surface of the suggestions: under the field and at least as wide as it. */
|
|
65
|
+
export function ComboboxPopup({ side = 'bottom', align = 'start', sideOffset = 6, className, ...props }: ComboboxPopupProps) {
|
|
66
|
+
const [placement, popupProps] = splitPlacement(props);
|
|
67
|
+
return (
|
|
68
|
+
<Autocomplete.Portal>
|
|
69
|
+
<Autocomplete.Positioner className="popup-positioner" render={renderPositioner} {...placement} side={side} align={align} sideOffset={sideOffset}>
|
|
70
|
+
<Autocomplete.Popup className={withClass('menu-popup min-w-(--anchor-width)', className)} {...popupProps} />
|
|
71
|
+
</Autocomplete.Positioner>
|
|
72
|
+
</Autocomplete.Portal>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ComboboxList(props: ComponentProps<typeof Autocomplete.List>) {
|
|
77
|
+
const { t } = useTranslation('ui');
|
|
78
|
+
return <Autocomplete.List aria-label={t('combobox.suggestions')} {...props} />;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ComboboxItemProps = ComponentProps<typeof Autocomplete.Item> & {
|
|
82
|
+
/* Before the label, such as a 14px icon or a file's mark. */
|
|
83
|
+
icon?: ReactNode;
|
|
84
|
+
/* Beside the label and the first to give way when the row is narrow, such as the folder a file sits in. */
|
|
85
|
+
hint?: ReactNode;
|
|
86
|
+
/* A second line under the label. */
|
|
87
|
+
description?: ReactNode;
|
|
88
|
+
/* At the end of the row, before the shortcut: a state, a count, a reference. */
|
|
89
|
+
trailing?: ReactNode;
|
|
90
|
+
shortcut?: Shortcut;
|
|
91
|
+
/* Leaves the row out: only what makes it an option (the highlight, Enter, the click) stays, for a row that lays out itself, such as lines of source. */
|
|
92
|
+
unstyled?: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* One suggestion. Its children are the label. `onClick` runs for a click and for Enter on the
|
|
97
|
+
* highlighted row; Base UI clicks the row for Enter, so a modifier held with Enter is read in the
|
|
98
|
+
* field's `onKeyDown` instead.
|
|
99
|
+
*/
|
|
100
|
+
export function ComboboxItem({ icon, hint, description, trailing, shortcut, unstyled = false, className, children, ...props }: ComboboxItemProps) {
|
|
101
|
+
if (unstyled) {
|
|
102
|
+
return (
|
|
103
|
+
<Autocomplete.Item className={className} {...props}>
|
|
104
|
+
{children}
|
|
105
|
+
</Autocomplete.Item>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const label = <span className="min-w-0 truncate">{children}</span>;
|
|
109
|
+
return (
|
|
110
|
+
<Autocomplete.Item className={withClass(clsx('menu-item', description !== undefined && 'items-start'), className)} {...props}>
|
|
111
|
+
{icon !== undefined && <span className="flex h-lh shrink-0 items-center text-text-faint">{icon}</span>}
|
|
112
|
+
{description === undefined ? (
|
|
113
|
+
label
|
|
114
|
+
) : (
|
|
115
|
+
<span className="flex min-w-0 flex-col">
|
|
116
|
+
{label}
|
|
117
|
+
<span className="truncate text-xs text-text-faint">{description}</span>
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
{hint !== undefined && <span className="min-w-0 shrink-1000 truncate text-xs/[inherit] text-text-faint">{hint}</span>}
|
|
121
|
+
{(trailing !== undefined || shortcut !== undefined) && (
|
|
122
|
+
<span className="ml-auto flex h-lh shrink-0 items-center gap-2 pl-3">
|
|
123
|
+
{trailing}
|
|
124
|
+
{shortcut !== undefined && <Kbd shortcut={shortcut} />}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
</Autocomplete.Item>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const ComboboxGroup = Autocomplete.Group;
|
|
132
|
+
|
|
133
|
+
export function ComboboxGroupLabel({ className, ...props }: ComponentProps<typeof Autocomplete.GroupLabel>) {
|
|
134
|
+
return <Autocomplete.GroupLabel className={withClass(MENU_LABEL, className)} {...props} />;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const ComboboxCollection = Autocomplete.Collection;
|
|
138
|
+
|
|
139
|
+
/* Its children show only while the list has no rows. Needs `items` on the root. */
|
|
140
|
+
export function ComboboxEmpty({ className, ...props }: ComponentProps<typeof Autocomplete.Empty>) {
|
|
141
|
+
return <Autocomplete.Empty className={withClass(LIST_NOTE, className)} {...props} />;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* A line read out as it changes, such as a search that is still running or how many it found. It stays mounted; only its children come and go. */
|
|
145
|
+
export function ComboboxStatus({ className, ...props }: ComponentProps<typeof Autocomplete.Status>) {
|
|
146
|
+
return <Autocomplete.Status className={withClass(LIST_NOTE, className)} {...props} />;
|
|
147
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* A command palette is a combobox in a dialog; its rows, groups and notes are the combobox's own. */
|
|
2
|
+
export {
|
|
3
|
+
ComboboxCollection as Collection,
|
|
4
|
+
ComboboxEmpty as Empty,
|
|
5
|
+
ComboboxGroup as Group,
|
|
6
|
+
ComboboxGroupLabel as GroupLabel,
|
|
7
|
+
ComboboxItem as Item,
|
|
8
|
+
ComboboxStatus as Status
|
|
9
|
+
} from '../combobox/parts.tsx';
|
|
10
|
+
export {
|
|
11
|
+
CommandPaletteFooter as Footer,
|
|
12
|
+
CommandPaletteInput as Input,
|
|
13
|
+
CommandPaletteList as List,
|
|
14
|
+
CommandPalettePopup as Popup,
|
|
15
|
+
CommandPaletteRoot as Root
|
|
16
|
+
} from './parts.tsx';
|