@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.
Files changed (63) hide show
  1. package/dist/Banner.d.ts +2 -1
  2. package/dist/Banner.js +92 -51
  3. package/dist/CopyButton.d.ts +24 -0
  4. package/dist/CopyButton.js +144 -0
  5. package/dist/ErrorBoundary.js +16 -18
  6. package/dist/ProjectSwitcher.js +66 -73
  7. package/dist/RelativeTime.d.ts +9 -0
  8. package/dist/RelativeTime.js +38 -0
  9. package/dist/Segmented.d.ts +4 -1
  10. package/dist/Segmented.js +111 -86
  11. package/dist/Toasts.js +1 -1
  12. package/dist/combobox/index.parts.d.ts +1 -0
  13. package/dist/combobox/index.parts.js +1 -0
  14. package/dist/combobox/parts.d.ts +32 -0
  15. package/dist/combobox/parts.js +556 -0
  16. package/dist/command-palette/index.parts.d.ts +2 -0
  17. package/dist/command-palette/index.parts.js +3 -0
  18. package/dist/command-palette/parts.d.ts +32 -0
  19. package/dist/command-palette/parts.js +422 -0
  20. package/dist/format/datetime.d.ts +1 -0
  21. package/dist/format/datetime.js +8 -0
  22. package/dist/format/duration.d.ts +1 -0
  23. package/dist/format/duration.js +24 -1
  24. package/dist/format/index.d.ts +2 -2
  25. package/dist/format/index.js +2 -2
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.js +5 -0
  28. package/dist/locales/en.d.ts +8 -0
  29. package/dist/locales/en.js +8 -0
  30. package/dist/locales/nl.d.ts +8 -0
  31. package/dist/locales/nl.js +8 -0
  32. package/dist/match.d.ts +1 -0
  33. package/dist/match.js +47 -0
  34. package/dist/segmented-keys.d.ts +3 -0
  35. package/dist/segmented-keys.js +33 -0
  36. package/dist/theme.css +15 -0
  37. package/dist/toast-store.d.ts +1 -0
  38. package/dist/toast-store.js +16 -6
  39. package/dist/workspace/TabStrip.d.ts +2 -1
  40. package/dist/workspace/TabStrip.js +432 -372
  41. package/package.json +1 -1
  42. package/src/Banner.tsx +33 -9
  43. package/src/CopyButton.tsx +86 -0
  44. package/src/ErrorBoundary.tsx +3 -4
  45. package/src/ProjectSwitcher.tsx +4 -2
  46. package/src/RelativeTime.tsx +44 -0
  47. package/src/Segmented.tsx +60 -33
  48. package/src/Toasts.tsx +1 -1
  49. package/src/combobox/index.parts.ts +12 -0
  50. package/src/combobox/parts.tsx +147 -0
  51. package/src/command-palette/index.parts.ts +16 -0
  52. package/src/command-palette/parts.tsx +145 -0
  53. package/src/format/datetime.ts +5 -0
  54. package/src/format/duration.ts +25 -1
  55. package/src/format/index.ts +2 -1
  56. package/src/index.ts +7 -0
  57. package/src/locales/en.ts +8 -0
  58. package/src/locales/nl.ts +8 -0
  59. package/src/match.ts +50 -0
  60. package/src/segmented-keys.ts +33 -0
  61. package/src/theme.css +15 -0
  62. package/src/toast-store.ts +16 -6
  63. 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
+ }
@@ -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
 
@@ -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);
@@ -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;
@@ -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
- /* A toast that went well takes itself away, and so does the offer to undo; a failure and a running action stay. */
76
- const schedule = (id: string, kind: ToastKind, persist: boolean): ToastDeadline | undefined => {
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 lifetime = kind === 'deleted' ? UNDO_MS : kind === 'success' && !persist ? SUCCESS_MS : null;
79
- if (lifetime === null) {
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.kind, toast.persist === true) } as T;
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.kind, merged.persist === true) };
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={() => onValueChange(item.id)}
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();