@adecore/ui 0.33.3 → 0.34.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/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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createContext, useContext, useRef, type ComponentProps, type ReactNode, type RefObject } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Autocomplete } from '@base-ui/react/autocomplete';
|
|
6
|
+
import { useRender } from '@base-ui/react/use-render';
|
|
7
|
+
import { withClass } from '../class-name.ts';
|
|
8
|
+
import { DialogPopup, DialogRoot, type DialogPopupProps } from '../dialog/parts.tsx';
|
|
9
|
+
import { Icon } from '../Icon.tsx';
|
|
10
|
+
import { Kbd } from '../Kbd.tsx';
|
|
11
|
+
import { mergeRefs } from '../merge-refs.ts';
|
|
12
|
+
import { KEY_SHORTCUTS } from '../shortcut.ts';
|
|
13
|
+
|
|
14
|
+
/* The field, for the popup to put the focus in when it opens: a control before it would take it otherwise. */
|
|
15
|
+
const InputRefContext = createContext<RefObject<HTMLInputElement | null> | null>(null);
|
|
16
|
+
|
|
17
|
+
/* A palette asks for a few words and shows rows of one line; a search whose rows are lines of source needs the wider one. */
|
|
18
|
+
const SIZE = {
|
|
19
|
+
md: 'w-[576px]',
|
|
20
|
+
lg: 'w-[760px]'
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
type AutocompleteProps<ItemValue> = Omit<
|
|
24
|
+
Autocomplete.Root.Props<ItemValue>,
|
|
25
|
+
'open' | 'defaultOpen' | 'onOpenChange' | 'onOpenChangeComplete' | 'inline' | 'openOnInputClick' | 'modal' | 'actionsRef' | 'items'
|
|
26
|
+
> & {
|
|
27
|
+
/* A flat list, or groups of `{ value, items }`. Any object with `items` is a group, as Base UI's overloads take it. */
|
|
28
|
+
items?: readonly ItemValue[] | readonly { readonly items: readonly ItemValue[] }[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type DialogProps = Pick<ComponentProps<typeof DialogRoot>, 'open' | 'defaultOpen' | 'onOpenChange' | 'onOpenChangeComplete'>;
|
|
32
|
+
|
|
33
|
+
export type CommandPaletteRootProps<ItemValue> = AutocompleteProps<ItemValue> & DialogProps;
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* A dialog around a list that is always open. `open` and its handlers are the dialog's; the rest
|
|
37
|
+
* is Base UI's autocomplete, so the query, the items and their filter work as in a combobox. The
|
|
38
|
+
* first row is highlighted and stays so while the pointer leaves the list, which is what Enter runs.
|
|
39
|
+
*/
|
|
40
|
+
export function CommandPaletteRoot<ItemValue>({
|
|
41
|
+
open,
|
|
42
|
+
defaultOpen,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
onOpenChangeComplete,
|
|
45
|
+
autoHighlight = 'always',
|
|
46
|
+
keepHighlight = true,
|
|
47
|
+
items,
|
|
48
|
+
children,
|
|
49
|
+
...props
|
|
50
|
+
}: CommandPaletteRootProps<ItemValue>) {
|
|
51
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
52
|
+
return (
|
|
53
|
+
<DialogRoot open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
|
|
54
|
+
<InputRefContext value={inputRef}>
|
|
55
|
+
{/* Its props take a flat list or groups; only its overloads split the two, which a generic caller cannot pick between. */}
|
|
56
|
+
<Autocomplete.Root<ItemValue>
|
|
57
|
+
{...props}
|
|
58
|
+
items={items as readonly ItemValue[] | undefined}
|
|
59
|
+
inline
|
|
60
|
+
open
|
|
61
|
+
autoHighlight={autoHighlight}
|
|
62
|
+
keepHighlight={keepHighlight}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</Autocomplete.Root>
|
|
66
|
+
</InputRefContext>
|
|
67
|
+
</DialogRoot>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type CommandPalettePopupProps = Omit<DialogPopupProps, 'size'> & {
|
|
72
|
+
size?: keyof typeof SIZE;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* The dialog's surface. It hangs from a fixed line near the top instead of the middle: the list
|
|
77
|
+
* changes height with every key, and a centered popup would walk up and down the screen.
|
|
78
|
+
*/
|
|
79
|
+
export function CommandPalettePopup({ size = 'md', initialFocus, className, ...props }: CommandPalettePopupProps) {
|
|
80
|
+
const { t } = useTranslation('ui');
|
|
81
|
+
const inputRef = useContext(InputRefContext);
|
|
82
|
+
return (
|
|
83
|
+
<DialogPopup
|
|
84
|
+
aria-label={t('palette.label')}
|
|
85
|
+
initialFocus={initialFocus ?? inputRef ?? undefined}
|
|
86
|
+
className={withClass(clsx('top-[18vh] flex flex-col [translate:-50%_0]', SIZE[size]), className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CommandPaletteInputProps = Omit<ComponentProps<typeof Autocomplete.Input>, 'className'> & {
|
|
93
|
+
/* In front of the text instead of the magnifier: the mark of a mode, a button back. */
|
|
94
|
+
leading?: ReactNode;
|
|
95
|
+
/* After the text, before the Escape hint: toggles of a search, a button that uses the text. */
|
|
96
|
+
trailing?: ReactNode;
|
|
97
|
+
/* The Escape key at the end of the field. Off where Escape does something else first. */
|
|
98
|
+
escapeHint?: boolean;
|
|
99
|
+
/* A pattern or a path read back letter by letter. */
|
|
100
|
+
mono?: boolean;
|
|
101
|
+
/* Goes to the row around the field; `ref` stays on the input. */
|
|
102
|
+
className?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/* The field across the top. It keeps the keys while the arrows move the highlight; a control beside it should not take the focus on a click. */
|
|
106
|
+
export function CommandPaletteInput({ leading, trailing, escapeHint = true, mono = false, className, ref, ...props }: CommandPaletteInputProps) {
|
|
107
|
+
const inputRef = useContext(InputRefContext);
|
|
108
|
+
return (
|
|
109
|
+
<div className={clsx('relative flex shrink-0 items-center gap-2 border-b border-border px-3', className)}>
|
|
110
|
+
{leading ?? <Icon icon={Search} size={14} className="shrink-0 text-text-faint" />}
|
|
111
|
+
<Autocomplete.Input
|
|
112
|
+
ref={mergeRefs(ref, inputRef ?? undefined)}
|
|
113
|
+
spellCheck={false}
|
|
114
|
+
className={clsx('h-11 min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint', mono && 'font-mono text-code')}
|
|
115
|
+
{...props}
|
|
116
|
+
/>
|
|
117
|
+
{trailing}
|
|
118
|
+
{escapeHint && <Kbd shortcut={KEY_SHORTCUTS.escape} variant="inline" className="shrink-0" />}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* The rows. It scrolls on its own, so the field and the footer stay put. */
|
|
124
|
+
export function CommandPaletteList({ className, ...props }: ComponentProps<typeof Autocomplete.List>) {
|
|
125
|
+
const { t } = useTranslation('ui');
|
|
126
|
+
return (
|
|
127
|
+
<Autocomplete.List
|
|
128
|
+
aria-label={t('palette.results')}
|
|
129
|
+
className={withClass('max-h-[50vh] min-h-0 overflow-y-auto overscroll-contain scroll-py-1.5 not-empty:p-1.5', className)}
|
|
130
|
+
{...props}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type CommandPaletteFooterProps = useRender.ComponentProps<'div'>;
|
|
136
|
+
|
|
137
|
+
/* Under the list: the keys that work here, a count, an action that leaves the palette. */
|
|
138
|
+
export function CommandPaletteFooter({ render, className, ref, ...props }: CommandPaletteFooterProps) {
|
|
139
|
+
return useRender({
|
|
140
|
+
render,
|
|
141
|
+
ref,
|
|
142
|
+
defaultTagName: 'div',
|
|
143
|
+
props: { ...props, className: clsx('flex shrink-0 items-center gap-3 border-t border-border px-3 py-2 text-xs text-text-faint', className) }
|
|
144
|
+
});
|
|
145
|
+
}
|
package/src/format/datetime.ts
CHANGED
|
@@ -26,6 +26,8 @@ export const formatDateTime = (at: Date | number, options: Intl.DateTimeFormatOp
|
|
|
26
26
|
const CLOCK: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
|
27
27
|
const WEEKDAY_CLOCK: Intl.DateTimeFormatOptions = { weekday: 'short', hour: '2-digit', minute: '2-digit' };
|
|
28
28
|
const HOUR: Intl.DateTimeFormatOptions = { hour: 'numeric' };
|
|
29
|
+
const WEEKDAY: Intl.DateTimeFormatOptions = { weekday: 'short' };
|
|
30
|
+
const WEEKDAY_LONG: Intl.DateTimeFormatOptions = { weekday: 'long' };
|
|
29
31
|
const DAY: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
|
30
32
|
const DAY_WITH_YEAR: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
|
|
31
33
|
const WEEKDAY_DAY: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' };
|
|
@@ -45,6 +47,9 @@ export const formatWeekdayClock = (at: Date | number): string => formatDateTime(
|
|
|
45
47
|
/* An hour of the day on its own, for the foot of a chart: `08`, or `8 AM` where that is the clock. */
|
|
46
48
|
export const formatHour = (at: Date | number): string => formatDateTime(at, HOUR);
|
|
47
49
|
|
|
50
|
+
/* The day of the week on its own, for the side of a grid by weekday: `Sat`, or `Saturday` when `long`. */
|
|
51
|
+
export const formatWeekday = (at: Date | number, width: 'short' | 'long' = 'short'): string => formatDateTime(at, width === 'long' ? WEEKDAY_LONG : WEEKDAY);
|
|
52
|
+
|
|
48
53
|
/* `19 Sep`, the short date a row falls back to once "days ago" stops meaning anything. */
|
|
49
54
|
export const formatDay = (at: Date | number, timeZone?: string): string => formatDateTime(at, timeZone === undefined ? DAY : { ...DAY, timeZone });
|
|
50
55
|
|
package/src/format/duration.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { relativeFormatter } from './locale.ts';
|
|
2
|
-
import { formatDecimal, formatNumber } from './number.ts';
|
|
2
|
+
import { formatDecimal, formatNumber, formatRounded } from './number.ts';
|
|
3
3
|
|
|
4
4
|
const SECOND = 1000;
|
|
5
5
|
const MINUTE = 60 * SECOND;
|
|
@@ -23,6 +23,30 @@ export const formatDuration = (ms: number): string => {
|
|
|
23
23
|
return `${formatDecimal(minutes / 60)} h`;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/*
|
|
27
|
+
* How long a short measurement took, below a second too: `0.4 ms`, `123 ms`, `1.42 s`, and as `formatDuration` from a
|
|
28
|
+
* minute. Each unit rounds before it is chosen, so 999.6 ms reads as `1 s` and not as `1,000 ms`.
|
|
29
|
+
*/
|
|
30
|
+
export const formatPreciseDuration = (ms: number): string => {
|
|
31
|
+
const value = Math.max(0, ms);
|
|
32
|
+
const tenths = Math.round(value * 10) / 10;
|
|
33
|
+
if (tenths < 10) {
|
|
34
|
+
return `${formatRounded(tenths, 1)} ms`;
|
|
35
|
+
}
|
|
36
|
+
if (Math.round(value) < SECOND) {
|
|
37
|
+
return `${formatNumber(Math.round(value))} ms`;
|
|
38
|
+
}
|
|
39
|
+
const hundredths = Math.round(value / 10) / 100;
|
|
40
|
+
if (hundredths < 10) {
|
|
41
|
+
return `${formatRounded(hundredths, 2)} s`;
|
|
42
|
+
}
|
|
43
|
+
const seconds = Math.round(value / 100) / 10;
|
|
44
|
+
if (seconds < 60) {
|
|
45
|
+
return `${formatRounded(seconds, 1)} s`;
|
|
46
|
+
}
|
|
47
|
+
return formatDuration(value);
|
|
48
|
+
};
|
|
49
|
+
|
|
26
50
|
/* How long a window still has, tight enough for the end of a bar: `4d 3h`, `12h 8m`, `9m`. */
|
|
27
51
|
export const formatCountdown = (ms: number): string => {
|
|
28
52
|
const left = Math.max(0, ms);
|
package/src/format/index.ts
CHANGED
|
@@ -11,11 +11,12 @@ export {
|
|
|
11
11
|
formatHour,
|
|
12
12
|
formatMoment,
|
|
13
13
|
formatNumericDate,
|
|
14
|
+
formatWeekday,
|
|
14
15
|
formatWeekdayClock,
|
|
15
16
|
formatWeekdayDay,
|
|
16
17
|
isSameDay
|
|
17
18
|
} from './datetime.ts';
|
|
18
|
-
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
|
|
19
|
+
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.ts';
|
|
19
20
|
export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
20
21
|
export {
|
|
21
22
|
formatBytes,
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* `exports.test.ts` turns any change to this list into a diff someone has to accept.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
export * as Combobox from './combobox/index.parts.ts';
|
|
7
|
+
export * as CommandPalette from './command-palette/index.parts.ts';
|
|
6
8
|
export * as ContextMenu from './context-menu/index.parts.ts';
|
|
7
9
|
export * as FileTree from './file-tree/index.parts.ts';
|
|
8
10
|
export * as Tree from './tree/index.parts.ts';
|
|
@@ -13,6 +15,8 @@ export * as Popover from './popover/index.parts.ts';
|
|
|
13
15
|
export * as PreviewCard from './preview-card/index.parts.ts';
|
|
14
16
|
export * as Tabs from './tabs/index.parts.ts';
|
|
15
17
|
|
|
18
|
+
export type { ComboboxInputProps, ComboboxItemProps, ComboboxPopupProps } from './combobox/parts.tsx';
|
|
19
|
+
export type { CommandPaletteFooterProps, CommandPaletteInputProps, CommandPalettePopupProps, CommandPaletteRootProps } from './command-palette/parts.tsx';
|
|
16
20
|
export type { DialogDescriptionProps, DialogFooterProps, DialogPopupProps, DialogTextProps, DialogTitleProps } from './dialog/parts.tsx';
|
|
17
21
|
export type {
|
|
18
22
|
MenuCheckProps,
|
|
@@ -35,6 +39,7 @@ export { Checkbox, type CheckboxProps } from './Checkbox.tsx';
|
|
|
35
39
|
export { ChoiceCards, type Choice, type ChoiceCardsProps } from './ChoiceCards.tsx';
|
|
36
40
|
export { CloseButton, type CloseButtonProps } from './CloseButton.tsx';
|
|
37
41
|
export { ColorSwatch, type ColorSwatchProps } from './ColorSwatch.tsx';
|
|
42
|
+
export { CopyButton, type CopyButtonProps } from './CopyButton.tsx';
|
|
38
43
|
export { ColorWheelPicker, type ColorWheelPickerProps } from './ColorWheelPicker.tsx';
|
|
39
44
|
export { ColumnResizeHandle, type ColumnResizeHandleProps } from './ColumnResizeHandle.tsx';
|
|
40
45
|
export { DisabledReason, type DisabledReasonProps } from './DisabledReason.tsx';
|
|
@@ -55,6 +60,7 @@ export { PanelEmpty, type PanelEmptyProps } from './PanelEmpty.tsx';
|
|
|
55
60
|
export { PanelHeader, type PanelHeaderProps } from './PanelHeader.tsx';
|
|
56
61
|
export { Pill, type PillProps } from './Pill.tsx';
|
|
57
62
|
export { PromptDialog, type PromptDialogProps } from './PromptDialog.tsx';
|
|
63
|
+
export { RelativeTime, type RelativeTimeProps } from './RelativeTime.tsx';
|
|
58
64
|
export { ProjectSwitcher, ProjectSwitcherGroups, type ProjectSwitcherItem, type ProjectSwitcherProps } from './ProjectSwitcher.tsx';
|
|
59
65
|
export { SectionLabel, type SectionLabelProps } from './SectionLabel.tsx';
|
|
60
66
|
export { SegmentBar, type SegmentBarPart, type SegmentBarProps } from './SegmentBar.tsx';
|
|
@@ -88,6 +94,7 @@ export type { LazyComponent } from './lazy.tsx';
|
|
|
88
94
|
export { LazyBoundary } from './LazyBoundary.tsx';
|
|
89
95
|
export type { LazyBoundaryProps } from './LazyBoundary.tsx';
|
|
90
96
|
export { UI_NAMESPACE, UI_RESOURCES } from './locales.ts';
|
|
97
|
+
export { matchScore } from './match.ts';
|
|
91
98
|
export { isApplePlatform } from './platform.ts';
|
|
92
99
|
export { prefetcher, type Loader, type WhenIdle } from './prefetch.ts';
|
|
93
100
|
export { selectAllWithin, selectionWithin } from './selection.ts';
|
package/src/locales/en.ts
CHANGED
|
@@ -2,6 +2,7 @@ export default {
|
|
|
2
2
|
action: {
|
|
3
3
|
cancel: 'Cancel',
|
|
4
4
|
copy: 'Copy',
|
|
5
|
+
copied: 'Copied',
|
|
5
6
|
copyError: 'Copy error',
|
|
6
7
|
dismiss: 'Dismiss',
|
|
7
8
|
reload: 'Reload',
|
|
@@ -38,6 +39,13 @@ export default {
|
|
|
38
39
|
search: 'Search',
|
|
39
40
|
noMatch: 'No match for “{{query}}”'
|
|
40
41
|
},
|
|
42
|
+
combobox: {
|
|
43
|
+
suggestions: 'Suggestions'
|
|
44
|
+
},
|
|
45
|
+
palette: {
|
|
46
|
+
label: 'Command palette',
|
|
47
|
+
results: 'Results'
|
|
48
|
+
},
|
|
41
49
|
iconPicker: {
|
|
42
50
|
label: 'Icon',
|
|
43
51
|
none: 'No icon',
|
package/src/locales/nl.ts
CHANGED
|
@@ -2,6 +2,7 @@ export default {
|
|
|
2
2
|
action: {
|
|
3
3
|
cancel: 'Annuleren',
|
|
4
4
|
copy: 'Kopiëren',
|
|
5
|
+
copied: 'Gekopieerd',
|
|
5
6
|
copyError: 'Fout kopiëren',
|
|
6
7
|
dismiss: 'Sluiten',
|
|
7
8
|
reload: 'Herladen',
|
|
@@ -38,6 +39,13 @@ export default {
|
|
|
38
39
|
search: 'Zoeken',
|
|
39
40
|
noMatch: 'Geen resultaat voor “{{query}}”'
|
|
40
41
|
},
|
|
42
|
+
combobox: {
|
|
43
|
+
suggestions: 'Suggesties'
|
|
44
|
+
},
|
|
45
|
+
palette: {
|
|
46
|
+
label: 'Opdrachtenpalet',
|
|
47
|
+
results: 'Resultaten'
|
|
48
|
+
},
|
|
41
49
|
iconPicker: {
|
|
42
50
|
label: 'Icoon',
|
|
43
51
|
none: 'Geen icoon',
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const WORD_START = /[\s\-_./:]/;
|
|
2
|
+
|
|
3
|
+
/* Whether `query` starts the text or one of its words. */
|
|
4
|
+
const startsWord = (text: string, query: string): boolean => {
|
|
5
|
+
let at = text.indexOf(query);
|
|
6
|
+
while (at !== -1) {
|
|
7
|
+
if (at === 0 || WORD_START.test(text[at - 1]!)) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
at = text.indexOf(query, at + 1);
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const lettersInOrder = (text: string, query: string): boolean => {
|
|
16
|
+
let at = 0;
|
|
17
|
+
for (const letter of query.replace(/\s+/g, '')) {
|
|
18
|
+
at = text.indexOf(letter, at);
|
|
19
|
+
if (at === -1) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
at += 1;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* How well `text` answers to what a person typed, ignoring case: 5 when it starts with the query,
|
|
29
|
+
* 4 when one of its words does, 3 when every typed word is in it and it starts with the first,
|
|
30
|
+
* 2 when every typed word is in it in any order, 1 when the letters are in it in order, and `null`
|
|
31
|
+
* when it does not match. An empty query matches everything with 0.
|
|
32
|
+
*/
|
|
33
|
+
export function matchScore(text: string, query: string): number | null {
|
|
34
|
+
const wanted = query.trim().toLowerCase();
|
|
35
|
+
if (wanted === '') {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const haystack = text.toLowerCase();
|
|
39
|
+
if (haystack.startsWith(wanted)) {
|
|
40
|
+
return 5;
|
|
41
|
+
}
|
|
42
|
+
if (startsWord(haystack, wanted)) {
|
|
43
|
+
return 4;
|
|
44
|
+
}
|
|
45
|
+
const words = wanted.split(/\s+/);
|
|
46
|
+
if (words.every((word) => haystack.includes(word))) {
|
|
47
|
+
return haystack.startsWith(words[0]!) ? 3 : 2;
|
|
48
|
+
}
|
|
49
|
+
return lettersInOrder(haystack, wanted) ? 1 : null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SegmentedOption } from './Segmented.tsx';
|
|
2
|
+
|
|
3
|
+
const STEPS: Readonly<Record<string, number>> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
4
|
+
|
|
5
|
+
// The index of the one tab stop: the picked option, or the first that can be picked when that one is missing or disabled.
|
|
6
|
+
export function segmentedStop<T extends string>(options: readonly SegmentedOption<T>[], value: T): number {
|
|
7
|
+
const picked = options.findIndex((option) => option.id === value);
|
|
8
|
+
return picked !== -1 && !options[picked]!.disabled ? picked : options.findIndex((option) => !option.disabled);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The index a key moves to from `index`, passing over disabled options and around the ends; `null` when the key moves nothing.
|
|
12
|
+
export function segmentedTarget(options: readonly SegmentedOption<string>[], index: number, key: string): number | null {
|
|
13
|
+
const nearest = (from: number, step: number): number | null => {
|
|
14
|
+
for (let offset = 0; offset < options.length; offset++) {
|
|
15
|
+
const candidate = (((from + step * offset) % options.length) + options.length) % options.length;
|
|
16
|
+
if (!options[candidate]!.disabled) {
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (key in STEPS) {
|
|
24
|
+
return nearest(index + STEPS[key]!, STEPS[key]!);
|
|
25
|
+
}
|
|
26
|
+
if (key === 'Home') {
|
|
27
|
+
return nearest(0, 1);
|
|
28
|
+
}
|
|
29
|
+
if (key === 'End') {
|
|
30
|
+
return nearest(options.length - 1, -1);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
package/src/theme.css
CHANGED
|
@@ -58,6 +58,14 @@
|
|
|
58
58
|
--status-error: #dc2626;
|
|
59
59
|
--positive: #15803d;
|
|
60
60
|
--positive-text: #ffffff;
|
|
61
|
+
/* The series of a chart, in order: the accent, then a categorical order checked for color vision
|
|
62
|
+
deficiency on both grounds: orange, aqua, yellow, magenta, green. */
|
|
63
|
+
--chart-1: var(--accent);
|
|
64
|
+
--chart-2: #eb6834;
|
|
65
|
+
--chart-3: #1baf7a;
|
|
66
|
+
--chart-4: #eda100;
|
|
67
|
+
--chart-5: #e87ba4;
|
|
68
|
+
--chart-6: #008300;
|
|
61
69
|
/* A control drawn over a picture rather than over the interface, the handle of a wipe, so it
|
|
62
70
|
stays the same in both themes. */
|
|
63
71
|
--media-handle: #ffffff;
|
|
@@ -122,6 +130,13 @@
|
|
|
122
130
|
/* Dark on light green: the dark theme's positive is a foreground color, so text sits on it the
|
|
123
131
|
other way round from the light theme, where the same token is a fill deep enough for white. */
|
|
124
132
|
--positive-text: #052e16;
|
|
133
|
+
/* Repeated so a dark theme nested under a light one resolves its own accent. */
|
|
134
|
+
--chart-1: var(--accent);
|
|
135
|
+
--chart-2: #d95926;
|
|
136
|
+
--chart-3: #199e70;
|
|
137
|
+
--chart-4: #c98500;
|
|
138
|
+
--chart-5: #d55181;
|
|
139
|
+
--chart-6: #008300;
|
|
125
140
|
--file-icon-gray: #adadb1;
|
|
126
141
|
--file-icon-red: #ff6762;
|
|
127
142
|
--file-icon-vermilion: #d5512f;
|
package/src/toast-store.ts
CHANGED
|
@@ -7,6 +7,9 @@ export const SUCCESS_MS = 4000;
|
|
|
7
7
|
// How long a deletion can still be taken back.
|
|
8
8
|
export const UNDO_MS = 8000;
|
|
9
9
|
|
|
10
|
+
// `setTimeout` fires at once past this, so a longer lifetime, `Infinity` too, is a toast that stays.
|
|
11
|
+
const LONGEST_TIMER_MS = 2 ** 31 - 1;
|
|
12
|
+
|
|
10
13
|
/* `deleted` says something went that can still come back, which is what its action is for. */
|
|
11
14
|
export type ToastKind = 'progress' | 'success' | 'error' | 'deleted';
|
|
12
15
|
|
|
@@ -38,6 +41,8 @@ export interface Toast {
|
|
|
38
41
|
actions?: ToastAction[];
|
|
39
42
|
/* A success that waits to be dismissed, for news that lands while nobody is looking yet. */
|
|
40
43
|
persist?: boolean;
|
|
44
|
+
/* Milliseconds until the toast takes itself away, whatever its kind; over the kind's own lifetime and `persist`. Zero or less is ignored. */
|
|
45
|
+
lifetime?: number;
|
|
41
46
|
/* Runs once the toast is gone, whether it ran out or was dismissed. */
|
|
42
47
|
onClose?: () => void;
|
|
43
48
|
/* Set by the store from the timer that takes the toast away, so what counts down on screen is that timer. */
|
|
@@ -72,11 +77,16 @@ export const createToastStore = <T extends Toast = Toast>(): ToastStoreHook<T> =
|
|
|
72
77
|
timers.delete(id);
|
|
73
78
|
};
|
|
74
79
|
|
|
75
|
-
/*
|
|
76
|
-
|
|
80
|
+
/*
|
|
81
|
+
* A toast that went well takes itself away, and so does the offer to undo; a failure and a running
|
|
82
|
+
* action stay, unless the toast names a lifetime of its own.
|
|
83
|
+
*/
|
|
84
|
+
const schedule = (id: string, toast: Pick<Toast, 'kind' | 'persist' | 'lifetime'>): ToastDeadline | undefined => {
|
|
77
85
|
stop(id);
|
|
78
|
-
const
|
|
79
|
-
|
|
86
|
+
const { kind, persist } = toast;
|
|
87
|
+
const own = toast.lifetime !== undefined && toast.lifetime > 0 ? toast.lifetime : undefined;
|
|
88
|
+
const lifetime = own ?? (kind === 'deleted' ? UNDO_MS : kind === 'success' && persist !== true ? SUCCESS_MS : null);
|
|
89
|
+
if (lifetime === null || lifetime > LONGEST_TIMER_MS) {
|
|
80
90
|
return undefined;
|
|
81
91
|
}
|
|
82
92
|
timers.set(
|
|
@@ -95,7 +105,7 @@ export const createToastStore = <T extends Toast = Toast>(): ToastStoreHook<T> =
|
|
|
95
105
|
show(toast) {
|
|
96
106
|
counter += 1;
|
|
97
107
|
const id = toast.id ?? `toast-${counter}`;
|
|
98
|
-
const next = { ...toast, id, deadline: schedule(id, toast
|
|
108
|
+
const next = { ...toast, id, deadline: schedule(id, toast) } as T;
|
|
99
109
|
const toasts = get().toasts;
|
|
100
110
|
set({ toasts: toasts.some((entry) => entry.id === id) ? toasts.map((entry) => (entry.id === id ? next : entry)) : [...toasts, next] });
|
|
101
111
|
return id;
|
|
@@ -106,7 +116,7 @@ export const createToastStore = <T extends Toast = Toast>(): ToastStoreHook<T> =
|
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
118
|
const merged: T = { ...current, ...patch };
|
|
109
|
-
const next: T = { ...merged, deadline: schedule(id, merged
|
|
119
|
+
const next: T = { ...merged, deadline: schedule(id, merged) };
|
|
110
120
|
set({ toasts: get().toasts.map((entry) => (entry.id === id ? next : entry)) });
|
|
111
121
|
},
|
|
112
122
|
dismiss(id) {
|
|
@@ -58,6 +58,7 @@ export interface TabStripProps extends Omit<useRender.ComponentProps<'div'>, 'ch
|
|
|
58
58
|
renderTab?(item: TabStripItem): ReactNode;
|
|
59
59
|
focused?: boolean;
|
|
60
60
|
insertAt?: number | null;
|
|
61
|
+
pressToClose?: boolean;
|
|
61
62
|
onClose?(id: string): void;
|
|
62
63
|
onDoubleClick?(id: string, event: MouseEvent<HTMLButtonElement>): void;
|
|
63
64
|
onContextMenu?(id: string, event: MouseEvent<HTMLSpanElement>): void;
|
|
@@ -74,6 +75,7 @@ export function TabStrip({
|
|
|
74
75
|
renderTab,
|
|
75
76
|
focused = true,
|
|
76
77
|
insertAt,
|
|
78
|
+
pressToClose = false,
|
|
77
79
|
onClose,
|
|
78
80
|
onDoubleClick,
|
|
79
81
|
onContextMenu,
|
|
@@ -169,6 +171,7 @@ export function TabStrip({
|
|
|
169
171
|
items,
|
|
170
172
|
value,
|
|
171
173
|
onValueChange,
|
|
174
|
+
pressToClose,
|
|
172
175
|
onClose,
|
|
173
176
|
onDoubleClick,
|
|
174
177
|
onContextMenu,
|
|
@@ -238,7 +241,7 @@ export function TabStrip({
|
|
|
238
241
|
|
|
239
242
|
interface TabContextValue extends Pick<
|
|
240
243
|
TabStripProps,
|
|
241
|
-
'items' | 'value' | 'onValueChange' | 'onClose' | 'onDoubleClick' | 'onContextMenu' | 'onDragStart' | 'onDragEnd'
|
|
244
|
+
'items' | 'value' | 'onValueChange' | 'pressToClose' | 'onClose' | 'onDoubleClick' | 'onContextMenu' | 'onDragStart' | 'onDragEnd'
|
|
242
245
|
> {
|
|
243
246
|
close(id: string, action?: () => void): void;
|
|
244
247
|
focus(index: number): void;
|
|
@@ -265,6 +268,12 @@ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDra
|
|
|
265
268
|
const index = items.findIndex((candidate) => candidate.id === item.id);
|
|
266
269
|
const close = (): void => context.close(item.id, onClose);
|
|
267
270
|
const closable = item.closable !== false && (onClose ?? context.onClose) !== undefined;
|
|
271
|
+
// Shift with Cmd, Ctrl or Alt stays the app's to give a meaning.
|
|
272
|
+
const closesOnPress = (event: MouseEvent): boolean =>
|
|
273
|
+
!!context.pressToClose &&
|
|
274
|
+
closable &&
|
|
275
|
+
!item.pinned &&
|
|
276
|
+
(event.button === 1 || (event.button === 0 && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey));
|
|
268
277
|
return useRender({
|
|
269
278
|
render,
|
|
270
279
|
ref,
|
|
@@ -279,6 +288,20 @@ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDra
|
|
|
279
288
|
props.onContextMenu?.(event);
|
|
280
289
|
context.onContextMenu?.(item.id, event);
|
|
281
290
|
},
|
|
291
|
+
onMouseDown: (event: MouseEvent<HTMLSpanElement>) => {
|
|
292
|
+
props.onMouseDown?.(event);
|
|
293
|
+
// A press that closes starts no text selection, takes no focus and no middle-button scroll.
|
|
294
|
+
if (closesOnPress(event)) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
onAuxClick: (event: MouseEvent<HTMLSpanElement>) => {
|
|
299
|
+
props.onAuxClick?.(event);
|
|
300
|
+
if (event.button === 1 && closesOnPress(event)) {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
close();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
282
305
|
children: (
|
|
283
306
|
<>
|
|
284
307
|
<Tooltip label={item.hint ?? item.label}>
|
|
@@ -291,7 +314,13 @@ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDra
|
|
|
291
314
|
tabIndex={item.id === value || (value === null && index === 0) ? 0 : -1}
|
|
292
315
|
className="ade-document-tab-open"
|
|
293
316
|
draggable={!!(onDragStart ?? context.onDragStart)}
|
|
294
|
-
onClick={() =>
|
|
317
|
+
onClick={(event) => {
|
|
318
|
+
if (closesOnPress(event)) {
|
|
319
|
+
close();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
onValueChange(item.id);
|
|
323
|
+
}}
|
|
295
324
|
onDoubleClick={(event) => (onDoubleClick ?? context.onDoubleClick)?.(item.id, event)}
|
|
296
325
|
onDragStart={(event) => {
|
|
297
326
|
event.stopPropagation();
|