@adecore/ui 0.14.0
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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/AccentSwatches.d.ts +15 -0
- package/dist/AccentSwatches.js +21 -0
- package/dist/Banner.d.ts +12 -0
- package/dist/Banner.js +18 -0
- package/dist/Button.d.ts +9 -0
- package/dist/Button.js +28 -0
- package/dist/ButtonGroup.d.ts +3 -0
- package/dist/ButtonGroup.js +11 -0
- package/dist/Checkbox.d.ts +11 -0
- package/dist/Checkbox.js +9 -0
- package/dist/ChoiceCards.d.ts +23 -0
- package/dist/ChoiceCards.js +21 -0
- package/dist/CloseButton.d.ts +15 -0
- package/dist/CloseButton.js +11 -0
- package/dist/ColorSwatch.d.ts +14 -0
- package/dist/ColorSwatch.js +28 -0
- package/dist/ColumnResizeHandle.d.ts +9 -0
- package/dist/ColumnResizeHandle.js +12 -0
- package/dist/DisabledReason.d.ts +6 -0
- package/dist/DisabledReason.js +8 -0
- package/dist/DockShell.d.ts +9 -0
- package/dist/DockShell.js +49 -0
- package/dist/EmptyState.d.ts +16 -0
- package/dist/EmptyState.js +11 -0
- package/dist/ErrorBoundary.d.ts +24 -0
- package/dist/ErrorBoundary.js +47 -0
- package/dist/Field.d.ts +17 -0
- package/dist/Field.js +48 -0
- package/dist/FileIcon.d.ts +8 -0
- package/dist/FileIcon.js +15 -0
- package/dist/Icon.d.ts +9 -0
- package/dist/Icon.js +12 -0
- package/dist/IconButton.d.ts +23 -0
- package/dist/IconButton.js +25 -0
- package/dist/IconPicker.d.ts +17 -0
- package/dist/IconPicker.js +93 -0
- package/dist/Input.d.ts +14 -0
- package/dist/Input.js +25 -0
- package/dist/Kbd.d.ts +23 -0
- package/dist/Kbd.js +20 -0
- package/dist/ListRow.d.ts +10 -0
- package/dist/ListRow.js +20 -0
- package/dist/Meter.d.ts +12 -0
- package/dist/Meter.js +7 -0
- package/dist/PanelEmpty.d.ts +16 -0
- package/dist/PanelEmpty.js +7 -0
- package/dist/PanelHeader.d.ts +6 -0
- package/dist/PanelHeader.js +20 -0
- package/dist/Pill.d.ts +27 -0
- package/dist/Pill.js +25 -0
- package/dist/ProjectSwitcher.d.ts +30 -0
- package/dist/ProjectSwitcher.js +18 -0
- package/dist/PromptDialog.d.ts +40 -0
- package/dist/PromptDialog.js +47 -0
- package/dist/SectionLabel.d.ts +3 -0
- package/dist/SectionLabel.js +16 -0
- package/dist/SegmentBar.d.ts +18 -0
- package/dist/SegmentBar.js +44 -0
- package/dist/Segmented.d.ts +17 -0
- package/dist/Segmented.js +7 -0
- package/dist/Select.d.ts +27 -0
- package/dist/Select.js +34 -0
- package/dist/Separator.d.ts +7 -0
- package/dist/Separator.js +8 -0
- package/dist/ShortcutHints.d.ts +1 -0
- package/dist/ShortcutHints.js +94 -0
- package/dist/Skeleton.d.ts +6 -0
- package/dist/Skeleton.js +6 -0
- package/dist/SlidingColumn.d.ts +19 -0
- package/dist/SlidingColumn.js +41 -0
- package/dist/Spinner.d.ts +6 -0
- package/dist/Spinner.js +7 -0
- package/dist/Stepper.d.ts +14 -0
- package/dist/Stepper.js +18 -0
- package/dist/Surface.d.ts +3 -0
- package/dist/Surface.js +16 -0
- package/dist/Switch.d.ts +10 -0
- package/dist/Switch.js +7 -0
- package/dist/TextMenu.d.ts +5 -0
- package/dist/TextMenu.js +31 -0
- package/dist/Tile.d.ts +11 -0
- package/dist/Tile.js +10 -0
- package/dist/Toasts.d.ts +9 -0
- package/dist/Toasts.js +58 -0
- package/dist/Tooltip.d.ts +16 -0
- package/dist/Tooltip.js +21 -0
- package/dist/UIProvider.d.ts +9 -0
- package/dist/UIProvider.js +26 -0
- package/dist/Waveform.d.ts +16 -0
- package/dist/Waveform.js +123 -0
- package/dist/Wipe.d.ts +12 -0
- package/dist/Wipe.js +47 -0
- package/dist/ZoomControls.d.ts +30 -0
- package/dist/ZoomControls.js +16 -0
- package/dist/class-name.d.ts +2 -0
- package/dist/class-name.js +3 -0
- package/dist/clipboard.d.ts +2 -0
- package/dist/clipboard.js +16 -0
- package/dist/context-menu/index.parts.d.ts +1 -0
- package/dist/context-menu/index.parts.js +2 -0
- package/dist/dialog/index.parts.d.ts +1 -0
- package/dist/dialog/index.parts.js +1 -0
- package/dist/dialog/parts.d.ts +36 -0
- package/dist/dialog/parts.js +55 -0
- package/dist/dialog-layer.d.ts +1 -0
- package/dist/dialog-layer.js +27 -0
- package/dist/error-boundary.d.ts +4 -0
- package/dist/error-boundary.js +34 -0
- package/dist/error-message.d.ts +1 -0
- package/dist/error-message.js +4 -0
- package/dist/field-context.d.ts +7 -0
- package/dist/field-context.js +4 -0
- package/dist/file-icon.d.ts +4 -0
- package/dist/file-icon.js +46 -0
- package/dist/floating.d.ts +5 -0
- package/dist/floating.js +6 -0
- package/dist/format/datetime.d.ts +11 -0
- package/dist/format/datetime.js +50 -0
- package/dist/format/duration.d.ts +5 -0
- package/dist/format/duration.js +76 -0
- package/dist/format/index.d.ts +6 -0
- package/dist/format/index.js +7 -0
- package/dist/format/locale.d.ts +17 -0
- package/dist/format/locale.js +105 -0
- package/dist/format/number.d.ts +8 -0
- package/dist/format/number.js +72 -0
- package/dist/format/regions.d.ts +6 -0
- package/dist/format/regions.js +15 -0
- package/dist/format/time-zone.d.ts +1 -0
- package/dist/format/time-zone.js +2 -0
- package/dist/icon-button-size.d.ts +3 -0
- package/dist/icon-button-size.js +3 -0
- package/dist/icon-picker.d.ts +23 -0
- package/dist/icon-picker.js +80 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +74 -0
- package/dist/lazy.d.ts +12 -0
- package/dist/lazy.js +104 -0
- package/dist/locales/en.json +59 -0
- package/dist/locales/nl.json +59 -0
- package/dist/locales.d.ts +4 -0
- package/dist/locales.js +17 -0
- package/dist/menu/index.parts.d.ts +1 -0
- package/dist/menu/index.parts.js +1 -0
- package/dist/menu/parts.d.ts +67 -0
- package/dist/menu/parts.js +113 -0
- package/dist/merge-refs.d.ts +2 -0
- package/dist/merge-refs.js +11 -0
- package/dist/modality.d.ts +1 -0
- package/dist/modality.js +29 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +9 -0
- package/dist/popover/index.parts.d.ts +1 -0
- package/dist/popover/index.parts.js +1 -0
- package/dist/popover/parts.d.ts +26 -0
- package/dist/popover/parts.js +28 -0
- package/dist/prefetch.d.ts +21 -0
- package/dist/prefetch.js +99 -0
- package/dist/preview-card/index.parts.d.ts +1 -0
- package/dist/preview-card/index.parts.js +1 -0
- package/dist/project-switcher-row.d.ts +6 -0
- package/dist/project-switcher-row.js +16 -0
- package/dist/selection.d.ts +2 -0
- package/dist/selection.js +22 -0
- package/dist/seti-icons.d.ts +5 -0
- package/dist/seti-icons.js +445 -0
- package/dist/settings/ConfirmDialog.d.ts +9 -0
- package/dist/settings/ConfirmDialog.js +9 -0
- package/dist/settings/DetailHeader.d.ts +10 -0
- package/dist/settings/DetailHeader.js +6 -0
- package/dist/settings/MasterDetail.d.ts +24 -0
- package/dist/settings/MasterDetail.js +16 -0
- package/dist/settings/SettingsDialog.d.ts +46 -0
- package/dist/settings/SettingsDialog.js +96 -0
- package/dist/settings/SettingsRow.d.ts +22 -0
- package/dist/settings/SettingsRow.js +30 -0
- package/dist/settings/SettingsSection.d.ts +14 -0
- package/dist/settings/SettingsSection.js +8 -0
- package/dist/settings/index.d.ts +7 -0
- package/dist/settings/index.js +8 -0
- package/dist/settings/target.d.ts +7 -0
- package/dist/settings/target.js +3 -0
- package/dist/shortcut-hints.d.ts +38 -0
- package/dist/shortcut-hints.js +81 -0
- package/dist/shortcut.d.ts +32 -0
- package/dist/shortcut.js +137 -0
- package/dist/testing/dedupe.d.ts +1 -0
- package/dist/testing/dedupe.js +73 -0
- package/dist/testing/fake-source.d.ts +8 -0
- package/dist/testing/fake-source.js +15 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/theme.css +562 -0
- package/dist/toast-store.d.ts +38 -0
- package/dist/toast-store.js +61 -0
- package/dist/useAsyncAction.d.ts +8 -0
- package/dist/useAsyncAction.js +23 -0
- package/dist/useColumnResize.d.ts +14 -0
- package/dist/useColumnResize.js +49 -0
- package/dist/useContentSize.d.ts +5 -0
- package/dist/useContentSize.js +21 -0
- package/dist/useMeasuredWidth.d.ts +1 -0
- package/dist/useMeasuredWidth.js +6 -0
- package/dist/useNow.d.ts +3 -0
- package/dist/useNow.js +41 -0
- package/dist/wipe-split.d.ts +7 -0
- package/dist/wipe-split.js +20 -0
- package/dist/zoom.d.ts +3 -0
- package/dist/zoom.js +6 -0
- package/package.json +92 -0
- package/src/AccentSwatches.tsx +73 -0
- package/src/Banner.tsx +43 -0
- package/src/Button.tsx +55 -0
- package/src/ButtonGroup.tsx +14 -0
- package/src/Checkbox.tsx +44 -0
- package/src/ChoiceCards.tsx +108 -0
- package/src/CloseButton.tsx +21 -0
- package/src/ColorSwatch.tsx +41 -0
- package/src/ColumnResizeHandle.tsx +23 -0
- package/src/DisabledReason.tsx +23 -0
- package/src/DockShell.tsx +76 -0
- package/src/EmptyState.tsx +48 -0
- package/src/ErrorBoundary.tsx +116 -0
- package/src/Field.tsx +116 -0
- package/src/FileIcon.tsx +27 -0
- package/src/Icon.tsx +22 -0
- package/src/IconButton.tsx +84 -0
- package/src/IconPicker.tsx +257 -0
- package/src/Input.tsx +78 -0
- package/src/Kbd.tsx +67 -0
- package/src/ListRow.tsx +26 -0
- package/src/Meter.tsx +41 -0
- package/src/PanelEmpty.tsx +37 -0
- package/src/PanelHeader.tsx +31 -0
- package/src/Pill.tsx +61 -0
- package/src/ProjectSwitcher.tsx +127 -0
- package/src/PromptDialog.tsx +168 -0
- package/src/SectionLabel.tsx +20 -0
- package/src/SegmentBar.tsx +128 -0
- package/src/Segmented.tsx +51 -0
- package/src/Select.tsx +135 -0
- package/src/Separator.tsx +15 -0
- package/src/ShortcutHints.tsx +118 -0
- package/src/Skeleton.tsx +13 -0
- package/src/SlidingColumn.tsx +91 -0
- package/src/Spinner.tsx +32 -0
- package/src/Stepper.tsx +67 -0
- package/src/Surface.tsx +20 -0
- package/src/Switch.tsx +32 -0
- package/src/TextMenu.tsx +59 -0
- package/src/Tile.tsx +49 -0
- package/src/Toasts.tsx +151 -0
- package/src/Tooltip.tsx +70 -0
- package/src/UIProvider.tsx +41 -0
- package/src/Waveform.tsx +199 -0
- package/src/Wipe.tsx +99 -0
- package/src/ZoomControls.tsx +99 -0
- package/src/class-name.ts +8 -0
- package/src/clipboard.ts +17 -0
- package/src/context-menu/index.parts.ts +21 -0
- package/src/dialog/index.parts.ts +10 -0
- package/src/dialog/parts.tsx +112 -0
- package/src/dialog-layer.ts +31 -0
- package/src/error-boundary.ts +40 -0
- package/src/error-message.ts +5 -0
- package/src/field-context.ts +12 -0
- package/src/file-icon.ts +52 -0
- package/src/floating.ts +9 -0
- package/src/format/datetime.ts +63 -0
- package/src/format/duration.ts +83 -0
- package/src/format/index.ts +20 -0
- package/src/format/locale.ts +153 -0
- package/src/format/number.ts +87 -0
- package/src/format/regions.ts +20 -0
- package/src/format/time-zone.ts +2 -0
- package/src/icon-button-size.ts +6 -0
- package/src/icon-picker.ts +112 -0
- package/src/index.ts +103 -0
- package/src/lazy.tsx +124 -0
- package/src/locales/en.json +59 -0
- package/src/locales/nl.json +59 -0
- package/src/locales.ts +21 -0
- package/src/menu/index.parts.ts +20 -0
- package/src/menu/parts.tsx +296 -0
- package/src/merge-refs.ts +14 -0
- package/src/modality.ts +37 -0
- package/src/platform.ts +10 -0
- package/src/popover/index.parts.ts +8 -0
- package/src/popover/parts.tsx +119 -0
- package/src/prefetch.ts +113 -0
- package/src/preview-card/index.parts.ts +1 -0
- package/src/project-switcher-row.tsx +51 -0
- package/src/selection.ts +24 -0
- package/src/seti-icons.ts +451 -0
- package/src/settings/ConfirmDialog.tsx +30 -0
- package/src/settings/DetailHeader.tsx +25 -0
- package/src/settings/MasterDetail.tsx +67 -0
- package/src/settings/SettingsDialog.tsx +342 -0
- package/src/settings/SettingsRow.tsx +89 -0
- package/src/settings/SettingsSection.tsx +46 -0
- package/src/settings/index.ts +16 -0
- package/src/settings/target.ts +12 -0
- package/src/shortcut-hints.ts +117 -0
- package/src/shortcut.ts +170 -0
- package/src/testing/dedupe.ts +93 -0
- package/src/testing/fake-source.ts +21 -0
- package/src/testing/index.ts +3 -0
- package/src/theme.css +562 -0
- package/src/toast-store.ts +120 -0
- package/src/useAsyncAction.ts +32 -0
- package/src/useColumnResize.ts +75 -0
- package/src/useContentSize.ts +27 -0
- package/src/useMeasuredWidth.ts +7 -0
- package/src/useNow.ts +49 -0
- package/src/wipe-split.ts +24 -0
- package/src/zoom.ts +9 -0
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from 'react';
|
|
2
|
+
import { Select as BaseSelect } from '@base-ui-components/react/select';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import { ChevronDown } from 'lucide-react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Icon } from './Icon.tsx';
|
|
7
|
+
import { MENU_LABEL, MenuCheck } from './menu/parts.tsx';
|
|
8
|
+
|
|
9
|
+
export interface SelectItem<T extends string> {
|
|
10
|
+
value: T;
|
|
11
|
+
label: string;
|
|
12
|
+
/* A second line under the label, for what the choice does. */
|
|
13
|
+
description?: string;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SelectGroup<T extends string> {
|
|
19
|
+
label: string;
|
|
20
|
+
items: SelectItem<T>[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SelectProps<T extends string> {
|
|
24
|
+
value: T | null;
|
|
25
|
+
onValueChange(value: T): void;
|
|
26
|
+
items: SelectItem<T>[] | SelectGroup<T>[];
|
|
27
|
+
/* The accessible name of the trigger; the row above it usually carries the visible one. */
|
|
28
|
+
label: string;
|
|
29
|
+
/* What the trigger shows while nothing is picked. */
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
size?: 'sm' | 'md';
|
|
32
|
+
variant?: 'outlined' | 'ghost';
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
align?: 'start' | 'end';
|
|
35
|
+
truncateValue?: boolean;
|
|
36
|
+
className?: string;
|
|
37
|
+
ref?: Ref<HTMLButtonElement>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isGroup = <T extends string>(entry: SelectItem<T> | SelectGroup<T>): entry is SelectGroup<T> => Array.isArray((entry as SelectGroup<T>).items);
|
|
41
|
+
|
|
42
|
+
const TRIGGER_SIZE = {
|
|
43
|
+
sm: 'h-7 gap-1 px-2',
|
|
44
|
+
md: 'h-8 gap-1.5 px-2'
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
const TRIGGER_VARIANT = {
|
|
48
|
+
outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
|
|
49
|
+
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
53
|
+
const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
|
|
54
|
+
|
|
55
|
+
/* A description makes the row two lines high; the box keeps the icon on the label's line. */
|
|
56
|
+
function Row<T extends string>({ item, picked }: { item: SelectItem<T>; picked: boolean }) {
|
|
57
|
+
return (
|
|
58
|
+
<BaseSelect.Item className={clsx('menu-item', item.description && 'items-start')} value={item.value} label={item.label} disabled={item.disabled}>
|
|
59
|
+
<MenuCheck kind="radio" checked={picked} />
|
|
60
|
+
{item.icon && <span className="flex h-lh shrink-0 items-center">{item.icon}</span>}
|
|
61
|
+
<span className="flex min-w-0 flex-col">
|
|
62
|
+
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
|
|
63
|
+
{item.description && <span className="text-xs text-text-faint">{item.description}</span>}
|
|
64
|
+
</span>
|
|
65
|
+
</BaseSelect.Item>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* The one select. A Base UI `Select` in the popup style the menus use, so a settings field and a
|
|
70
|
+
picker in a toolbar read the same. Base UI brings the keyboard along (arrows,
|
|
71
|
+
Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat. */
|
|
72
|
+
export function Select<T extends string>({
|
|
73
|
+
value,
|
|
74
|
+
onValueChange,
|
|
75
|
+
items,
|
|
76
|
+
label,
|
|
77
|
+
placeholder,
|
|
78
|
+
size = 'md',
|
|
79
|
+
variant = 'outlined',
|
|
80
|
+
disabled,
|
|
81
|
+
align = 'start',
|
|
82
|
+
truncateValue = true,
|
|
83
|
+
className,
|
|
84
|
+
ref
|
|
85
|
+
}: SelectProps<T>) {
|
|
86
|
+
const { t } = useTranslation('ui');
|
|
87
|
+
const groups = items.length > 0 && isGroup(items[0]!) ? (items as SelectGroup<T>[]) : null;
|
|
88
|
+
const flat = groups ? groups.flatMap((group) => group.items) : (items as SelectItem<T>[]);
|
|
89
|
+
const selected = flat.find((item) => item.value === value) ?? null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<BaseSelect.Root
|
|
93
|
+
items={flat.map((item) => ({ value: item.value, label: item.label }))}
|
|
94
|
+
value={value}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
onValueChange={(next) => onValueChange(next as T)}
|
|
97
|
+
>
|
|
98
|
+
<BaseSelect.Trigger
|
|
99
|
+
ref={ref}
|
|
100
|
+
aria-label={label}
|
|
101
|
+
className={clsx(
|
|
102
|
+
'flex shrink-0 items-center whitespace-nowrap text-xs',
|
|
103
|
+
TRIGGER_SIZE[size],
|
|
104
|
+
TRIGGER_VARIANT[variant],
|
|
105
|
+
TRIGGER_DISABLED,
|
|
106
|
+
className
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{selected?.icon}
|
|
110
|
+
<BaseSelect.Value className={clsx(truncateValue && 'truncate')}>{() => selected?.label ?? placeholder ?? t('action.select')}</BaseSelect.Value>
|
|
111
|
+
<BaseSelect.Icon className="ml-auto flex shrink-0 pl-1">
|
|
112
|
+
<Icon icon={ChevronDown} size={14} />
|
|
113
|
+
</BaseSelect.Icon>
|
|
114
|
+
</BaseSelect.Trigger>
|
|
115
|
+
<BaseSelect.Portal>
|
|
116
|
+
<BaseSelect.Positioner className="popup-positioner" side="bottom" align={align} sideOffset={6} alignItemWithTrigger={false}>
|
|
117
|
+
<BaseSelect.Popup className="menu-popup">
|
|
118
|
+
<BaseSelect.List className="max-h-80 overflow-auto">
|
|
119
|
+
{groups
|
|
120
|
+
? groups.map((group) => (
|
|
121
|
+
<BaseSelect.Group key={group.label}>
|
|
122
|
+
<BaseSelect.GroupLabel className={MENU_LABEL}>{group.label}</BaseSelect.GroupLabel>
|
|
123
|
+
{group.items.map((item) => (
|
|
124
|
+
<Row key={item.value} item={item} picked={item.value === value} />
|
|
125
|
+
))}
|
|
126
|
+
</BaseSelect.Group>
|
|
127
|
+
))
|
|
128
|
+
: flat.map((item) => <Row key={item.value} item={item} picked={item.value === value} />)}
|
|
129
|
+
</BaseSelect.List>
|
|
130
|
+
</BaseSelect.Popup>
|
|
131
|
+
</BaseSelect.Positioner>
|
|
132
|
+
</BaseSelect.Portal>
|
|
133
|
+
</BaseSelect.Root>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
export interface SeparatorProps {
|
|
5
|
+
orientation?: 'vertical' | 'horizontal';
|
|
6
|
+
className?: string;
|
|
7
|
+
ref?: Ref<HTMLSpanElement>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* A hairline between two groups of controls in a toolbar-like row. It carries no margin of its own.
|
|
11
|
+
The row's gap puts 8px on either side of it, which is what makes the line read as a divider and
|
|
12
|
+
not as a group of its own. Between two groups of menu rows it is `Menu.Separator`. */
|
|
13
|
+
export function Separator({ orientation = 'vertical', className, ref }: SeparatorProps) {
|
|
14
|
+
return <span ref={ref} aria-hidden className={clsx(orientation === 'vertical' ? 'h-4 w-px' : 'h-px w-full', 'shrink-0 bg-border', className)} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { isApplePlatform } from './platform.ts';
|
|
4
|
+
import { formatShortcut } from './shortcut.ts';
|
|
5
|
+
import { ModifierHold, placeHint, shortcutHintTargets } from './shortcut-hints.ts';
|
|
6
|
+
|
|
7
|
+
interface PlacedHint {
|
|
8
|
+
id: number;
|
|
9
|
+
text: string;
|
|
10
|
+
style: CSSProperties;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Whether a person can see the button now: on screen, not faded out, and not under a dialog or another node. */
|
|
14
|
+
const isSeen = (element: Element, rect: DOMRect): boolean => {
|
|
15
|
+
if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const hit = document.elementFromPoint((rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2);
|
|
22
|
+
return hit !== null && element.contains(hit);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const measureHints = (apple: boolean): PlacedHint[] => {
|
|
26
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
27
|
+
const hints: PlacedHint[] = [];
|
|
28
|
+
for (const [element, target] of shortcutHintTargets()) {
|
|
29
|
+
const rect = element.getBoundingClientRect();
|
|
30
|
+
if (!isSeen(element, rect)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const placement = placeHint(rect, viewport);
|
|
34
|
+
hints.push({
|
|
35
|
+
id: target.id,
|
|
36
|
+
text: formatShortcut(target.shortcut, apple),
|
|
37
|
+
style: { left: placement.left, right: placement.right, top: placement.top, translate: `${placement.translateX} ${placement.translateY}` }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return hints;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* Every visible button's shortcut, under the button, for as long as the modifier is held on its own.
|
|
45
|
+
* Mounted once; the listener sits on the window in the capture phase so a terminal that keeps a key to
|
|
46
|
+
* itself still takes the hints down. An embedded page (`<webview>`) has the keyboard to itself and shows none.
|
|
47
|
+
*/
|
|
48
|
+
export function ShortcutHints() {
|
|
49
|
+
const [shown, setShown] = useState(false);
|
|
50
|
+
const [hints, setHints] = useState<PlacedHint[]>([]);
|
|
51
|
+
const holdRef = useRef<ModifierHold | null>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const hold = new ModifierHold(isApplePlatform(), setShown);
|
|
55
|
+
holdRef.current = hold;
|
|
56
|
+
const onKeyDown = (e: KeyboardEvent): void => hold.keyDown(e);
|
|
57
|
+
const onKeyUp = (e: KeyboardEvent): void => hold.keyUp(e);
|
|
58
|
+
// A click or a zoom with the modifier down is a gesture, not someone looking for a shortcut.
|
|
59
|
+
const cancel = (): void => hold.cancel();
|
|
60
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
61
|
+
window.addEventListener('keyup', onKeyUp, true);
|
|
62
|
+
window.addEventListener('pointerdown', cancel, true);
|
|
63
|
+
window.addEventListener('wheel', cancel, { capture: true, passive: true });
|
|
64
|
+
window.addEventListener('blur', cancel);
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
67
|
+
window.removeEventListener('keyup', onKeyUp, true);
|
|
68
|
+
window.removeEventListener('pointerdown', cancel, true);
|
|
69
|
+
window.removeEventListener('wheel', cancel, true);
|
|
70
|
+
window.removeEventListener('blur', cancel);
|
|
71
|
+
hold.cancel();
|
|
72
|
+
};
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
// Measured before paint and then every frame, so a hint never shows where its button was.
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
if (!shown) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const apple = isApplePlatform();
|
|
81
|
+
let frame = 0;
|
|
82
|
+
let last = '';
|
|
83
|
+
const measure = (): void => {
|
|
84
|
+
// Focus that moved into a page takes the key-up with it, so the embedder never hears the modifier let go.
|
|
85
|
+
if (document.activeElement?.tagName === 'WEBVIEW') {
|
|
86
|
+
holdRef.current?.cancel();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const next = measureHints(apple);
|
|
90
|
+
const signature = JSON.stringify(next);
|
|
91
|
+
if (signature !== last) {
|
|
92
|
+
last = signature;
|
|
93
|
+
setHints(next);
|
|
94
|
+
}
|
|
95
|
+
frame = requestAnimationFrame(measure);
|
|
96
|
+
};
|
|
97
|
+
measure();
|
|
98
|
+
return () => cancelAnimationFrame(frame);
|
|
99
|
+
}, [shown]);
|
|
100
|
+
|
|
101
|
+
if (!shown) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return createPortal(
|
|
105
|
+
<div aria-hidden className="pointer-events-none fixed inset-0 z-(--z-shortcut-hints)">
|
|
106
|
+
{hints.map((hint) => (
|
|
107
|
+
<kbd
|
|
108
|
+
key={hint.id}
|
|
109
|
+
className="absolute rounded-md bg-text px-1.5 font-sans text-xs/tight font-medium whitespace-nowrap text-surface shadow-float"
|
|
110
|
+
style={hint.style}
|
|
111
|
+
>
|
|
112
|
+
{hint.text}
|
|
113
|
+
</kbd>
|
|
114
|
+
))}
|
|
115
|
+
</div>,
|
|
116
|
+
document.body
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/Skeleton.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
export interface SkeletonProps {
|
|
5
|
+
/* The width, and a height other than the one line of 16px. */
|
|
6
|
+
className?: string;
|
|
7
|
+
ref?: Ref<HTMLSpanElement>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* A bar where a value will be, while the answer is still on its way. */
|
|
11
|
+
export function Skeleton({ className, ref }: SkeletonProps) {
|
|
12
|
+
return <span ref={ref} className={clsx('block h-4 animate-pulse rounded bg-surface-sunken', className)} aria-hidden />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref, type RefObject } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { ColumnResizeHandle } from './ColumnResizeHandle.tsx';
|
|
4
|
+
import { mergeRefs } from './merge-refs.ts';
|
|
5
|
+
import { useColumnResize } from './useColumnResize.ts';
|
|
6
|
+
|
|
7
|
+
// How long the open and close motion takes; the same number as `.sliding-column` in `theme.css`.
|
|
8
|
+
const TRANSITION_MS = 200;
|
|
9
|
+
|
|
10
|
+
export interface SlidingColumnProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
/* Already clamped by the caller, which knows what has to stay beside it (`clampColumnSize`). */
|
|
13
|
+
width: number;
|
|
14
|
+
bounds: { min: number; max(): number };
|
|
15
|
+
onWidthChange(width: number): void;
|
|
16
|
+
/*
|
|
17
|
+
* The width lands without the motion, for a column whose place is being restored as the page
|
|
18
|
+
* loads; only what a person does afterwards should animate.
|
|
19
|
+
*/
|
|
20
|
+
instant?: boolean;
|
|
21
|
+
/*
|
|
22
|
+
* The column takes the keyboard itself, for a panel with shortcuts of its own. It is focusable
|
|
23
|
+
* by script alone, so opening something in it hands it the keys without a click first.
|
|
24
|
+
*/
|
|
25
|
+
body?: {
|
|
26
|
+
ref: RefObject<HTMLDivElement | null>;
|
|
27
|
+
onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>): void;
|
|
28
|
+
};
|
|
29
|
+
className?: string;
|
|
30
|
+
/* The outer column, for bounds that measure what is beside it rather than the whole window. */
|
|
31
|
+
ref?: Ref<HTMLElement>;
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* A panel that slides in and out along the right edge. The outer column is 0 wide and inert while
|
|
37
|
+
* closed; the contents sit in an inner column of the stored width, so they do not reflow while it
|
|
38
|
+
* moves, and they stay mounted until the slide is over, so a close plays out. The left edge is the
|
|
39
|
+
* drag handle.
|
|
40
|
+
*/
|
|
41
|
+
export function SlidingColumn({ open, width, bounds, onWidthChange, instant = false, body, className, ref, children }: SlidingColumnProps) {
|
|
42
|
+
/* Closed and done animating. Until then the contents stay mounted, so a close plays out. */
|
|
43
|
+
const [settled, setSettled] = useState(!open);
|
|
44
|
+
/* A width that lands without a transition fires no `transitionend`, so the motion it would have
|
|
45
|
+
ended is over in the same commit that starts it. */
|
|
46
|
+
if (instant && settled !== !open) {
|
|
47
|
+
setSettled(!open);
|
|
48
|
+
}
|
|
49
|
+
const present = open || !settled;
|
|
50
|
+
const column = useRef<HTMLElement>(null);
|
|
51
|
+
const { startResize } = useColumnResize(column, { ...bounds, size: width, from: 'right', onSize: onWidthChange });
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (open || settled) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Reduced motion and a hidden tab paint no width change, so no `transitionend` arrives.
|
|
58
|
+
const timer = window.setTimeout(() => setSettled(true), TRANSITION_MS + 50);
|
|
59
|
+
return () => {
|
|
60
|
+
window.clearTimeout(timer);
|
|
61
|
+
};
|
|
62
|
+
}, [open, settled]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<aside
|
|
66
|
+
ref={mergeRefs(column, ref)}
|
|
67
|
+
inert={!open}
|
|
68
|
+
data-instant={instant ? '' : undefined}
|
|
69
|
+
className={clsx('sliding-column flex h-full shrink-0 justify-end overflow-hidden', className)}
|
|
70
|
+
style={{ width: open ? width : 0 }}
|
|
71
|
+
onTransitionEnd={(event) => {
|
|
72
|
+
if (event.propertyName === 'width' && event.target === event.currentTarget) {
|
|
73
|
+
setSettled(!open);
|
|
74
|
+
}
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{present && (
|
|
78
|
+
<div
|
|
79
|
+
ref={body?.ref}
|
|
80
|
+
tabIndex={body ? -1 : undefined}
|
|
81
|
+
className="relative flex h-full shrink-0 flex-col border-l border-border bg-surface outline-none"
|
|
82
|
+
style={{ width }}
|
|
83
|
+
onKeyDown={body?.onKeyDown}
|
|
84
|
+
>
|
|
85
|
+
{open && <ColumnResizeHandle from="right" onPointerDown={startResize} />}
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</aside>
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentProps, CSSProperties } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
|
|
4
|
+
const DOTS = [0, 1, 2];
|
|
5
|
+
|
|
6
|
+
export interface SpinnerProps extends Omit<ComponentProps<'span'>, 'children'> {
|
|
7
|
+
/* In pixels, on the steps of an icon: 12, 14, 16 or 20. */
|
|
8
|
+
size?: number;
|
|
9
|
+
/* What runs, read by a screen reader. Left out where the words beside it already say so, which hides the spinner as an icon is hidden. */
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Work in progress: three dots that leap over each other in the color of the text around them. With reduced motion they stand still. */
|
|
14
|
+
export function Spinner({ size = 16, label, className, style, ref, ...props }: SpinnerProps) {
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
ref={ref}
|
|
18
|
+
role={label === undefined ? undefined : 'img'}
|
|
19
|
+
aria-label={label}
|
|
20
|
+
aria-hidden={label === undefined ? true : undefined}
|
|
21
|
+
{...props}
|
|
22
|
+
className={clsx('spinner', className)}
|
|
23
|
+
style={{ ...style, '--spinner-size': `${size}px` } as CSSProperties}
|
|
24
|
+
>
|
|
25
|
+
{DOTS.map((dot) => (
|
|
26
|
+
<span key={dot} className="spinner-hop" style={{ '--spinner-step': dot } as CSSProperties}>
|
|
27
|
+
<span className="spinner-dot" />
|
|
28
|
+
</span>
|
|
29
|
+
))}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/Stepper.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Minus, Plus } from 'lucide-react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { ButtonGroup } from './ButtonGroup.tsx';
|
|
6
|
+
import { formatFixed } from './format/number.ts';
|
|
7
|
+
import { IconButton } from './IconButton.tsx';
|
|
8
|
+
|
|
9
|
+
export interface StepperProps {
|
|
10
|
+
value: number;
|
|
11
|
+
onValueChange(value: number): void;
|
|
12
|
+
min: number;
|
|
13
|
+
max: number;
|
|
14
|
+
step: number;
|
|
15
|
+
/* Places after the separator, always shown: `1,0`, `1,5`. Defaults to the places `step` is written with. */
|
|
16
|
+
decimals?: number;
|
|
17
|
+
/* Printed right after the number, such as `px` or `%`. */
|
|
18
|
+
unit?: string;
|
|
19
|
+
label: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
ref?: Ref<HTMLDivElement>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const placesOf = (step: number): number => String(step).split('.')[1]?.length ?? 0;
|
|
25
|
+
|
|
26
|
+
/* A number between minus and plus, in one sunken group so the three read as a single control. */
|
|
27
|
+
export function Stepper({ value, onValueChange, min, max, step, decimals = placesOf(step), unit, label, className, ref }: StepperProps) {
|
|
28
|
+
const { t } = useTranslation('ui');
|
|
29
|
+
const reading = (amount: number) => (
|
|
30
|
+
<>
|
|
31
|
+
{formatFixed(amount, decimals)}
|
|
32
|
+
{unit && <span className="text-text-muted">{unit}</span>}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
const nudge = (direction: -1 | 1): void => {
|
|
36
|
+
const next = Math.round((value + direction * step) * 100) / 100;
|
|
37
|
+
onValueChange(Math.min(max, Math.max(min, next)));
|
|
38
|
+
};
|
|
39
|
+
return (
|
|
40
|
+
<ButtonGroup ref={ref} className={clsx('shrink-0 rounded-lg bg-surface-sunken p-0.5', className)} role="group" aria-label={label}>
|
|
41
|
+
<IconButton
|
|
42
|
+
icon={Minus}
|
|
43
|
+
size="sm"
|
|
44
|
+
label={t('controls.stepper.smallerFor', { label })}
|
|
45
|
+
tooltip={t('controls.stepper.smaller')}
|
|
46
|
+
disabled={value <= min}
|
|
47
|
+
onClick={() => nudge(-1)}
|
|
48
|
+
/>
|
|
49
|
+
{/* The ends sit invisible in the same cell, so the widest of them sets the width and a step never moves the buttons. */}
|
|
50
|
+
<span className="grid min-w-10 px-1 text-center text-xs text-text tabular-nums">
|
|
51
|
+
<span className="invisible col-start-1 row-start-1">{reading(min)}</span>
|
|
52
|
+
<span className="invisible col-start-1 row-start-1">{reading(max)}</span>
|
|
53
|
+
<span className="col-start-1 row-start-1" aria-live="polite">
|
|
54
|
+
{reading(value)}
|
|
55
|
+
</span>
|
|
56
|
+
</span>
|
|
57
|
+
<IconButton
|
|
58
|
+
icon={Plus}
|
|
59
|
+
size="sm"
|
|
60
|
+
label={t('controls.stepper.largerFor', { label })}
|
|
61
|
+
tooltip={t('controls.stepper.larger')}
|
|
62
|
+
disabled={value >= max}
|
|
63
|
+
onClick={() => nudge(1)}
|
|
64
|
+
/>
|
|
65
|
+
</ButtonGroup>
|
|
66
|
+
);
|
|
67
|
+
}
|
package/src/Surface.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
3
|
+
|
|
4
|
+
const FLOAT = 'border border-border bg-[color-mix(in_srgb,var(--surface-raised)_88%,transparent)] shadow-float backdrop-blur-[14px]';
|
|
5
|
+
|
|
6
|
+
export type SurfaceProps = useRender.ComponentProps<'div'>;
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The glass card that floats over the content under it: a dock, a banner, a toast, a find bar, a
|
|
10
|
+
* chip over a scrolling thread. It brings the ground, the border and the shadow; the caller brings
|
|
11
|
+
* the shape (radius, padding, height) and the position.
|
|
12
|
+
*/
|
|
13
|
+
export function Surface({ render, className, ref, ...props }: SurfaceProps) {
|
|
14
|
+
return useRender({
|
|
15
|
+
render,
|
|
16
|
+
ref,
|
|
17
|
+
defaultTagName: 'div',
|
|
18
|
+
props: { ...props, className: clsx(FLOAT, className) }
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/Switch.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Ref } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
|
|
4
|
+
|
|
5
|
+
export interface SwitchProps {
|
|
6
|
+
checked: boolean;
|
|
7
|
+
onCheckedChange(checked: boolean): void;
|
|
8
|
+
/* The accessible name; the row it sits in usually carries the visible one. */
|
|
9
|
+
label: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
ref?: Ref<HTMLButtonElement>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* On or off, as a switch rather than a checkbox: a setting that takes effect the moment it flips. */
|
|
16
|
+
export function Switch({ checked, onCheckedChange, label, disabled, className, ref }: SwitchProps) {
|
|
17
|
+
return (
|
|
18
|
+
<BaseSwitch.Root
|
|
19
|
+
ref={ref}
|
|
20
|
+
checked={checked}
|
|
21
|
+
onCheckedChange={(next) => onCheckedChange(next)}
|
|
22
|
+
aria-label={label}
|
|
23
|
+
disabled={disabled}
|
|
24
|
+
className={clsx(
|
|
25
|
+
'relative h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent data-disabled:opacity-50',
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<BaseSwitch.Thumb className="block h-4 w-4 rounded-full bg-surface-raised shadow-raised transition-transform data-checked:translate-x-4" />
|
|
30
|
+
</BaseSwitch.Root>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/TextMenu.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useRef, useState, type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
import { Copy, Scan } from 'lucide-react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { copyText } from './clipboard.ts';
|
|
5
|
+
import { Icon } from './Icon.tsx';
|
|
6
|
+
import { Kbd } from './Kbd.tsx';
|
|
7
|
+
import { mergeRefs } from './merge-refs.ts';
|
|
8
|
+
import { ContextMenuRoot, ContextMenuTrigger, MenuItem, MenuPopup, MenuSeparator } from './menu/parts.tsx';
|
|
9
|
+
import { isApplePlatform } from './platform.ts';
|
|
10
|
+
import { selectAllWithin, selectionWithin } from './selection.ts';
|
|
11
|
+
import { EDIT_SHORTCUTS, matchesShortcut } from './shortcut.ts';
|
|
12
|
+
|
|
13
|
+
export type TextMenuProps = ComponentProps<'div'> & {
|
|
14
|
+
/* What the surface itself can be asked, under a line of their own: `ContextMenu.Item`s. */
|
|
15
|
+
items?: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* A block of text and the menu that belongs to it: Copy for what is selected inside it, Select all
|
|
20
|
+
* for the whole of it, on Cmd+A as well. Anywhere text can be selected a right-click has to offer
|
|
21
|
+
* to copy it, and an app that draws every menu itself offers nothing at all without one of these.
|
|
22
|
+
*/
|
|
23
|
+
export function TextMenu({ children, items, ref, ...rest }: TextMenuProps) {
|
|
24
|
+
const { t } = useTranslation('ui');
|
|
25
|
+
const host = useRef<HTMLDivElement>(null);
|
|
26
|
+
// Read when the menu opens: a selection made after that is not the one the click was about.
|
|
27
|
+
const [selection, setSelection] = useState('');
|
|
28
|
+
|
|
29
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
30
|
+
if (!matchesShortcut(EDIT_SHORTCUTS.selectAll, event, isApplePlatform())) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
selectAllWithin(host.current);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ContextMenuRoot onOpenChange={(open) => setSelection(open ? selectionWithin(host.current) : '')}>
|
|
39
|
+
<ContextMenuTrigger {...rest} ref={mergeRefs(host, ref)} onKeyDown={onKeyDown}>
|
|
40
|
+
{children}
|
|
41
|
+
</ContextMenuTrigger>
|
|
42
|
+
<MenuPopup>
|
|
43
|
+
<MenuItem disabled={selection === ''} onClick={() => copyText(selection)}>
|
|
44
|
+
<Icon icon={Copy} size={14} /> {t('action.copy')} <Kbd shortcut={EDIT_SHORTCUTS.copy} />
|
|
45
|
+
</MenuItem>
|
|
46
|
+
<MenuSeparator />
|
|
47
|
+
<MenuItem onClick={() => selectAllWithin(host.current)}>
|
|
48
|
+
<Icon icon={Scan} size={14} /> {t('action.selectAll')} <Kbd shortcut={EDIT_SHORTCUTS.selectAll} />
|
|
49
|
+
</MenuItem>
|
|
50
|
+
{items !== undefined && (
|
|
51
|
+
<>
|
|
52
|
+
<MenuSeparator />
|
|
53
|
+
{items}
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
</MenuPopup>
|
|
57
|
+
</ContextMenuRoot>
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/Tile.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Kbd } from './Kbd.tsx';
|
|
4
|
+
import type { Shortcut } from './shortcut.ts';
|
|
5
|
+
|
|
6
|
+
export interface TileProps extends Omit<ComponentProps<'button'>, 'title'> {
|
|
7
|
+
icon: ReactNode;
|
|
8
|
+
title: string;
|
|
9
|
+
/* One short line under the title: where it acts, or why it waits. */
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
shortcut?: Shortcut;
|
|
12
|
+
/* The one tile a screen leads with. */
|
|
13
|
+
primary?: boolean;
|
|
14
|
+
/* `sm` for a narrow column like the sidebar. */
|
|
15
|
+
size?: 'md' | 'sm';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* One thing to start with, as a card that is a button: a start screen and the empty places of an app
|
|
20
|
+
* use it, so a place to begin looks the same wherever it is offered.
|
|
21
|
+
*/
|
|
22
|
+
export function Tile({ icon, title, description, shortcut, primary = false, size = 'md', className, type = 'button', ...rest }: TileProps) {
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
type={type}
|
|
26
|
+
className={clsx(
|
|
27
|
+
'flex min-w-0 items-center border bg-surface text-left hover:bg-surface-hover disabled:opacity-50 disabled:hover:bg-surface',
|
|
28
|
+
size === 'md' ? 'gap-3 rounded-xl p-3' : 'gap-2 rounded-lg px-2 py-1.5',
|
|
29
|
+
primary ? 'border-accent' : 'border-border',
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
{...rest}
|
|
33
|
+
>
|
|
34
|
+
<span
|
|
35
|
+
className={clsx(
|
|
36
|
+
'grid size-8 shrink-0 place-items-center rounded-lg',
|
|
37
|
+
primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted'
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{icon}
|
|
41
|
+
</span>
|
|
42
|
+
<span className="flex min-w-0 grow flex-col">
|
|
43
|
+
<span className="truncate text-sm font-medium text-text">{title}</span>
|
|
44
|
+
{description && <span className="truncate text-xs text-text-muted">{description}</span>}
|
|
45
|
+
</span>
|
|
46
|
+
{shortcut && <Kbd shortcut={shortcut} variant="inline" className="shrink-0" />}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|