@adecore/ui 0.35.5 → 0.37.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/Button.d.ts +2 -3
- package/dist/Button.js +21 -46
- package/dist/Checkbox.js +1 -1
- package/dist/ChoiceCards.js +2 -2
- package/dist/IconPicker.d.ts +2 -1
- package/dist/IconPicker.js +504 -217
- package/dist/Input.d.ts +5 -3
- package/dist/Input.js +127 -112
- package/dist/Segmented.js +1 -1
- package/dist/SplitButton.d.ts +17 -0
- package/dist/SplitButton.js +237 -0
- package/dist/Stepper.js +1 -1
- package/dist/Switch.js +1 -1
- package/dist/Tile.js +2 -2
- package/dist/button-class.d.ts +6 -0
- package/dist/button-class.js +92 -0
- package/dist/command-palette/context.d.ts +10 -0
- package/dist/command-palette/context.js +5 -0
- package/dist/command-palette/index.parts.d.ts +1 -0
- package/dist/command-palette/index.parts.js +2 -1
- package/dist/command-palette/parts.d.ts +1 -1
- package/dist/command-palette/parts.js +12 -3
- package/dist/command-palette/virtual.d.ts +20 -0
- package/dist/command-palette/virtual.js +155 -0
- package/dist/file-tree/empty-lines.d.ts +11 -0
- package/dist/file-tree/empty-lines.js +89 -0
- package/dist/file-tree/highlight.d.ts +4 -0
- package/dist/file-tree/highlight.js +75 -0
- package/dist/file-tree/parts.d.ts +9 -1
- package/dist/file-tree/parts.js +297 -205
- package/dist/file-tree/style.js +13 -0
- package/dist/file-tree/use-empty-lines.d.ts +3 -0
- package/dist/file-tree/use-empty-lines.js +176 -0
- package/dist/file-tree/use-file-tree.js +5 -1
- package/dist/file-tree/use-slots.d.ts +16 -2
- package/dist/file-tree/use-slots.js +186 -43
- package/dist/format/index.d.ts +1 -1
- package/dist/format/index.js +1 -1
- package/dist/format/locale.d.ts +1 -0
- package/dist/format/locale.js +4 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/locales/en.d.ts +1 -0
- package/dist/locales/en.js +1 -0
- package/dist/locales/nl.d.ts +1 -0
- package/dist/locales/nl.js +1 -0
- package/dist/select-shared.d.ts +1 -1
- package/dist/select-shared.js +1 -1
- package/dist/settings/SettingsDialog.js +1 -1
- package/dist/theme.css +33 -3
- package/package.json +2 -1
- package/src/Button.tsx +3 -36
- package/src/Checkbox.tsx +1 -1
- package/src/ChoiceCards.tsx +2 -2
- package/src/IconPicker.tsx +220 -136
- package/src/Input.tsx +19 -10
- package/src/Segmented.tsx +1 -1
- package/src/SplitButton.tsx +72 -0
- package/src/Stepper.tsx +6 -1
- package/src/Switch.tsx +1 -1
- package/src/Tile.tsx +2 -2
- package/src/button-class.ts +111 -0
- package/src/command-palette/context.ts +16 -0
- package/src/command-palette/index.parts.ts +2 -0
- package/src/command-palette/parts.tsx +11 -12
- package/src/command-palette/virtual.tsx +147 -0
- package/src/file-tree/empty-lines.ts +105 -0
- package/src/file-tree/highlight.ts +80 -0
- package/src/file-tree/parts.tsx +42 -11
- package/src/file-tree/style.ts +13 -0
- package/src/file-tree/use-empty-lines.ts +115 -0
- package/src/file-tree/use-file-tree.ts +2 -1
- package/src/file-tree/use-slots.ts +176 -17
- package/src/format/index.ts +1 -1
- package/src/format/locale.ts +13 -7
- package/src/index.ts +3 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/nl.ts +1 -0
- package/src/select-shared.ts +2 -1
- package/src/settings/SettingsDialog.tsx +1 -1
- package/src/theme.css +33 -3
package/src/IconPicker.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import { Ban, Search, X } from 'lucide-react';
|
|
3
|
+
import { Ban, ChevronDown, Search, X } from 'lucide-react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Button } from './Button.tsx';
|
|
5
6
|
import { EmptyState } from './EmptyState.tsx';
|
|
6
7
|
import { formatNumber } from './format/number.ts';
|
|
7
8
|
import { Icon } from './Icon.tsx';
|
|
8
9
|
import { IconButton } from './IconButton.tsx';
|
|
9
|
-
import { cellForKey, escapeClearsSearch, filterSections, isGrouped, sectionsOf, tabStopOf, type IconPickerIcons } from './icon-picker.ts';
|
|
10
|
+
import { cellForKey, escapeClearsSearch, filterSections, isGrouped, sectionsOf, tabStopOf, type IconPickerIcons, type IconSection } from './icon-picker.ts';
|
|
11
|
+
import { PopoverPopup, PopoverRoot, PopoverTrigger } from './popover/parts.tsx';
|
|
10
12
|
import { SectionLabel } from './SectionLabel.tsx';
|
|
11
13
|
import { Tooltip } from './Tooltip.tsx';
|
|
12
14
|
|
|
@@ -25,15 +27,17 @@ export interface IconPickerProps {
|
|
|
25
27
|
onValueChange(name: string): void;
|
|
26
28
|
/* More words a search finds an icon by, beside its name and its group's label, by icon name. */
|
|
27
29
|
keywords?: Readonly<Record<string, readonly string[]>>;
|
|
28
|
-
/* A search field on top of the grid
|
|
30
|
+
/* A search field on top of the grid. On by default for groups, off for a flat set. */
|
|
29
31
|
searchable?: boolean;
|
|
30
32
|
/* The grid as a scroll area this many rows high, with room for a heading. Without it the grid is as tall as its icons. */
|
|
31
33
|
rows?: number;
|
|
32
34
|
disabled?: boolean;
|
|
33
35
|
/* Clearing the icon. A surface that falls back to a default of its own offers that instead. */
|
|
34
36
|
onClear?: () => void;
|
|
35
|
-
/* The label above the
|
|
37
|
+
/* The label above the trigger. A surface that also takes an image calls this something else, or the two read as one list. */
|
|
36
38
|
label?: string;
|
|
39
|
+
/* Leaves the label out of sight, for a row that names the control beside it; it still names the trigger. */
|
|
40
|
+
hideLabel?: boolean;
|
|
37
41
|
className?: string;
|
|
38
42
|
ref?: Ref<HTMLDivElement>;
|
|
39
43
|
}
|
|
@@ -49,9 +53,9 @@ const markStuckHeadings = (area: HTMLElement): void => {
|
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
/*
|
|
52
|
-
* The mark a thing wears, one of a set of Lucide icons,
|
|
53
|
-
*
|
|
54
|
-
*
|
|
56
|
+
* The mark a thing wears, one of a set of Lucide icons, behind a trigger drawn like a field. The
|
|
57
|
+
* popover holds a search over the grid. The grid is one tab stop: arrows move the focus and only
|
|
58
|
+
* Enter, Space or a click chooses, since a caller may save every choice at once.
|
|
55
59
|
*/
|
|
56
60
|
export function IconPicker({
|
|
57
61
|
icons,
|
|
@@ -63,25 +67,120 @@ export function IconPicker({
|
|
|
63
67
|
disabled = false,
|
|
64
68
|
onClear,
|
|
65
69
|
label,
|
|
70
|
+
hideLabel = false,
|
|
66
71
|
className,
|
|
67
72
|
ref
|
|
68
73
|
}: IconPickerProps) {
|
|
69
74
|
const { t } = useTranslation('ui');
|
|
70
75
|
const id = useId();
|
|
71
|
-
const
|
|
76
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
const sections = sectionsOf(icons);
|
|
79
|
+
const glyph = value === null ? undefined : sections.flatMap((section) => section.icons).find(([icon]) => icon === value)?.[1];
|
|
80
|
+
const name = label ?? t('iconPicker.label');
|
|
81
|
+
const clearable = onClear !== undefined && value !== null && !disabled;
|
|
82
|
+
|
|
83
|
+
const choose = (icon: string): void => {
|
|
84
|
+
onValueChange(icon);
|
|
85
|
+
setOpen(false);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const clear = (): void => {
|
|
89
|
+
onClear?.();
|
|
90
|
+
setOpen(false);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div ref={ref} className={clsx('flex flex-col gap-1.5', className)}>
|
|
95
|
+
<SectionLabel id={`${id}-label`} render={<div />} className={clsx(hideLabel && 'sr-only')}>
|
|
96
|
+
{name}
|
|
97
|
+
</SectionLabel>
|
|
98
|
+
<PopoverRoot open={open} onOpenChange={setOpen}>
|
|
99
|
+
<div className="flex gap-1.5">
|
|
100
|
+
<PopoverTrigger
|
|
101
|
+
ref={trigger}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
aria-labelledby={`${id}-label ${id}-value`}
|
|
104
|
+
className="field flex w-auto min-w-0 items-center gap-2 text-left hover:bg-surface-hover data-disabled:cursor-default data-disabled:opacity-50 data-disabled:hover:bg-field data-[popup-open]:bg-surface-hover"
|
|
105
|
+
>
|
|
106
|
+
{glyph && <Icon icon={glyph} size={16} className="shrink-0 text-text" />}
|
|
107
|
+
{/* The icon says which it is; its name is there for a screen reader. */}
|
|
108
|
+
<span id={`${id}-value`} className={clsx('min-w-0 truncate', glyph ? 'sr-only' : 'text-text-faint')}>
|
|
109
|
+
{value ?? t('iconPicker.placeholder')}
|
|
110
|
+
</span>
|
|
111
|
+
<Icon icon={ChevronDown} size={14} className="shrink-0 text-text-faint" />
|
|
112
|
+
</PopoverTrigger>
|
|
113
|
+
{clearable && (
|
|
114
|
+
<Tooltip label={t('iconPicker.none')}>
|
|
115
|
+
<Button
|
|
116
|
+
variant="secondary"
|
|
117
|
+
className="w-8 shrink-0 px-0!"
|
|
118
|
+
aria-label={t('iconPicker.none')}
|
|
119
|
+
onClick={() => {
|
|
120
|
+
onClear();
|
|
121
|
+
trigger.current?.focus();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<Icon icon={X} size={16} />
|
|
125
|
+
</Button>
|
|
126
|
+
</Tooltip>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
<PopoverPopup
|
|
130
|
+
variant="picker"
|
|
131
|
+
aria-label={name}
|
|
132
|
+
className="w-90"
|
|
133
|
+
initialFocus={() =>
|
|
134
|
+
document.getElementById(`${id}-search`) ?? document.getElementById(`${id}-grid`)?.querySelector<HTMLElement>('[tabindex="0"]') ?? false
|
|
135
|
+
}
|
|
136
|
+
finalFocus={trigger}
|
|
137
|
+
>
|
|
138
|
+
<IconPickerPanel
|
|
139
|
+
id={id}
|
|
140
|
+
name={name}
|
|
141
|
+
sections={sections}
|
|
142
|
+
headed={isGrouped(icons)}
|
|
143
|
+
value={value}
|
|
144
|
+
keywords={keywords}
|
|
145
|
+
searchable={searchable}
|
|
146
|
+
rows={rows}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
onChoose={choose}
|
|
149
|
+
onClear={onClear && clear}
|
|
150
|
+
/>
|
|
151
|
+
</PopoverPopup>
|
|
152
|
+
</PopoverRoot>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface IconPickerPanelProps {
|
|
158
|
+
id: string;
|
|
159
|
+
name: string;
|
|
160
|
+
sections: readonly IconSection[];
|
|
161
|
+
headed: boolean;
|
|
162
|
+
value: string | null;
|
|
163
|
+
keywords: Readonly<Record<string, readonly string[]>> | undefined;
|
|
164
|
+
searchable: boolean;
|
|
165
|
+
rows: number | undefined;
|
|
166
|
+
disabled: boolean;
|
|
167
|
+
onChoose(name: string): void;
|
|
168
|
+
onClear: (() => void) | undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* What the popover holds. It mounts on every open, so a search and the focus start over each time. */
|
|
172
|
+
function IconPickerPanel({ id, name, sections, headed, value, keywords, searchable, rows, disabled, onChoose, onClear }: IconPickerPanelProps) {
|
|
173
|
+
const { t } = useTranslation('ui');
|
|
72
174
|
const input = useRef<HTMLInputElement>(null);
|
|
175
|
+
const area = useRef<HTMLDivElement>(null);
|
|
73
176
|
const [query, setQuery] = useState('');
|
|
74
177
|
const [focused, setFocused] = useState<string | null>(null);
|
|
75
|
-
const headed = isGrouped(icons);
|
|
76
|
-
const framed = searchable || rows !== undefined;
|
|
77
|
-
const sections = sectionsOf(icons);
|
|
78
178
|
const shown = filterSections(sections, query, keywords);
|
|
79
179
|
const tabStop = tabStopOf(
|
|
80
|
-
shown.flatMap((section) => section.icons.map(([
|
|
180
|
+
shown.flatMap((section) => section.icons.map(([icon]) => icon)),
|
|
81
181
|
focused,
|
|
82
182
|
value
|
|
83
183
|
);
|
|
84
|
-
const name = label ?? t('iconPicker.label');
|
|
85
184
|
|
|
86
185
|
// Opens with the chosen icon in the middle of the area: a person changing an icon often starts from the current one.
|
|
87
186
|
useLayoutEffect(() => {
|
|
@@ -101,6 +200,17 @@ export function IconPicker({
|
|
|
101
200
|
}
|
|
102
201
|
};
|
|
103
202
|
|
|
203
|
+
// Escape empties a search first, wherever the focus is, and only an empty one reaches the popover and closes it.
|
|
204
|
+
const clearSearchFirst = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
205
|
+
if (!escapeClearsSearch(event.key, query)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
search('');
|
|
211
|
+
input.current?.focus();
|
|
212
|
+
};
|
|
213
|
+
|
|
104
214
|
const moveFocus = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
105
215
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
106
216
|
return;
|
|
@@ -122,135 +232,109 @@ export function IconPicker({
|
|
|
122
232
|
cells[to]?.focus();
|
|
123
233
|
};
|
|
124
234
|
|
|
125
|
-
const grid = (
|
|
126
|
-
<div
|
|
127
|
-
ref={area}
|
|
128
|
-
id={`${id}-grid`}
|
|
129
|
-
role="radiogroup"
|
|
130
|
-
aria-label={name}
|
|
131
|
-
className={clsx(framed && 'relative overflow-y-auto overscroll-contain', framed && (headed ? 'scroll-pt-7' : 'px-1.5 pt-1.5 pb-2'))}
|
|
132
|
-
style={rows === undefined ? undefined : { height: rows * ROW_HEIGHT + (headed ? HEADED_EXTRA : BARE_EXTRA) }}
|
|
133
|
-
onScroll={(event) => markStuckHeadings(event.currentTarget)}
|
|
134
|
-
onKeyDown={moveFocus}
|
|
135
|
-
>
|
|
136
|
-
{shown.length === 0 && query.trim() !== '' ? (
|
|
137
|
-
<EmptyState className="h-full">{t('iconPicker.noMatch', { query: query.trim() })}</EmptyState>
|
|
138
|
-
) : (
|
|
139
|
-
shown.map((section, index) => {
|
|
140
|
-
const cells = (
|
|
141
|
-
<div
|
|
142
|
-
key={section.id}
|
|
143
|
-
className={clsx('grid grid-cols-[repeat(auto-fill,minmax(28px,1fr))] gap-1', framed && section.label !== null && 'px-1.5 pb-2')}
|
|
144
|
-
>
|
|
145
|
-
{section.icons.map(([icon, glyph]) => (
|
|
146
|
-
<Tooltip key={icon} label={icon} name>
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
role="radio"
|
|
150
|
-
aria-checked={value === icon}
|
|
151
|
-
tabIndex={icon === tabStop ? 0 : -1}
|
|
152
|
-
disabled={disabled}
|
|
153
|
-
className={clsx(
|
|
154
|
-
'flex h-7 items-center justify-center rounded-md hover:bg-surface-hover disabled:opacity-50',
|
|
155
|
-
value === icon ? 'bg-surface-active text-text' : 'text-text-muted'
|
|
156
|
-
)}
|
|
157
|
-
onFocus={() => setFocused(icon)}
|
|
158
|
-
onClick={() => onValueChange(icon)}
|
|
159
|
-
>
|
|
160
|
-
<Icon icon={glyph} size={16} />
|
|
161
|
-
</button>
|
|
162
|
-
</Tooltip>
|
|
163
|
-
))}
|
|
164
|
-
</div>
|
|
165
|
-
);
|
|
166
|
-
if (section.label === null) {
|
|
167
|
-
return cells;
|
|
168
|
-
}
|
|
169
|
-
const headingId = `${id}-group-${index}`;
|
|
170
|
-
return (
|
|
171
|
-
<div key={section.id} role="group" aria-labelledby={headingId}>
|
|
172
|
-
<SectionLabel
|
|
173
|
-
render={<div />}
|
|
174
|
-
data-icon-heading=""
|
|
175
|
-
className={clsx(
|
|
176
|
-
'flex h-7 items-center gap-2',
|
|
177
|
-
framed ? 'sticky top-0 z-10 bg-surface px-2.5 data-stuck:shadow-[0_1px_0_var(--border)]' : 'mt-1'
|
|
178
|
-
)}
|
|
179
|
-
>
|
|
180
|
-
<span id={headingId} className="truncate">
|
|
181
|
-
{section.label}
|
|
182
|
-
</span>
|
|
183
|
-
<span className="ms-auto shrink-0">{formatNumber(section.icons.length)}</span>
|
|
184
|
-
</SectionLabel>
|
|
185
|
-
{cells}
|
|
186
|
-
</div>
|
|
187
|
-
);
|
|
188
|
-
})
|
|
189
|
-
)}
|
|
190
|
-
</div>
|
|
191
|
-
);
|
|
192
|
-
|
|
193
235
|
return (
|
|
194
|
-
<div
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
236
|
+
<div onKeyDown={clearSearchFirst}>
|
|
237
|
+
{searchable && (
|
|
238
|
+
<div className="flex h-9 items-center gap-2 border-b border-border pr-1 pl-2.5">
|
|
239
|
+
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
240
|
+
<input
|
|
241
|
+
ref={input}
|
|
242
|
+
id={`${id}-search`}
|
|
243
|
+
type="text"
|
|
244
|
+
value={query}
|
|
245
|
+
placeholder={t('iconPicker.search')}
|
|
246
|
+
aria-label={t('iconPicker.search')}
|
|
247
|
+
aria-controls={`${id}-grid`}
|
|
248
|
+
spellCheck={false}
|
|
249
|
+
autoComplete="off"
|
|
250
|
+
disabled={disabled}
|
|
251
|
+
className="h-full min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint"
|
|
252
|
+
onChange={(event) => search(event.target.value)}
|
|
253
|
+
/>
|
|
254
|
+
{query && (
|
|
200
255
|
<IconButton
|
|
201
|
-
icon={
|
|
256
|
+
icon={X}
|
|
202
257
|
size="sm"
|
|
203
|
-
label={t('iconPicker.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
258
|
+
label={t('iconPicker.clear')}
|
|
259
|
+
tooltip={false}
|
|
260
|
+
onClick={() => {
|
|
261
|
+
search('');
|
|
262
|
+
input.current?.focus();
|
|
263
|
+
}}
|
|
207
264
|
/>
|
|
208
|
-
</>
|
|
209
|
-
)}
|
|
210
|
-
</SectionLabel>
|
|
211
|
-
{framed ? (
|
|
212
|
-
// The frame wears the field's outline while its search has the focus; the search draws none of its own.
|
|
213
|
-
<div className="field h-auto overflow-hidden p-0 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-accent">
|
|
214
|
-
{searchable && (
|
|
215
|
-
<div className="flex h-8 items-center gap-2 border-b border-border pr-1 pl-2.5">
|
|
216
|
-
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
217
|
-
<input
|
|
218
|
-
ref={input}
|
|
219
|
-
type="text"
|
|
220
|
-
value={query}
|
|
221
|
-
placeholder={t('iconPicker.search')}
|
|
222
|
-
aria-label={t('iconPicker.search')}
|
|
223
|
-
aria-controls={`${id}-grid`}
|
|
224
|
-
spellCheck={false}
|
|
225
|
-
autoComplete="off"
|
|
226
|
-
disabled={disabled}
|
|
227
|
-
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint"
|
|
228
|
-
onChange={(event) => search(event.target.value)}
|
|
229
|
-
onKeyDown={(event) => {
|
|
230
|
-
if (escapeClearsSearch(event.key, query)) {
|
|
231
|
-
event.stopPropagation();
|
|
232
|
-
search('');
|
|
233
|
-
}
|
|
234
|
-
}}
|
|
235
|
-
/>
|
|
236
|
-
{query && (
|
|
237
|
-
<IconButton
|
|
238
|
-
icon={X}
|
|
239
|
-
size="sm"
|
|
240
|
-
label={t('iconPicker.clear')}
|
|
241
|
-
tooltip={false}
|
|
242
|
-
onClick={() => {
|
|
243
|
-
search('');
|
|
244
|
-
input.current?.focus();
|
|
245
|
-
}}
|
|
246
|
-
/>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
265
|
)}
|
|
250
|
-
{grid}
|
|
251
266
|
</div>
|
|
252
|
-
)
|
|
253
|
-
|
|
267
|
+
)}
|
|
268
|
+
<div
|
|
269
|
+
ref={area}
|
|
270
|
+
id={`${id}-grid`}
|
|
271
|
+
role="radiogroup"
|
|
272
|
+
aria-label={name}
|
|
273
|
+
className={clsx('relative overflow-y-auto overscroll-contain', headed ? 'scroll-pt-7' : 'px-1.5 pt-1.5 pb-2')}
|
|
274
|
+
style={rows === undefined ? undefined : { height: rows * ROW_HEIGHT + (headed ? HEADED_EXTRA : BARE_EXTRA) }}
|
|
275
|
+
onScroll={(event) => markStuckHeadings(event.currentTarget)}
|
|
276
|
+
onKeyDown={moveFocus}
|
|
277
|
+
>
|
|
278
|
+
{shown.length === 0 && query.trim() !== '' ? (
|
|
279
|
+
<EmptyState className="h-full">{t('iconPicker.noMatch', { query: query.trim() })}</EmptyState>
|
|
280
|
+
) : (
|
|
281
|
+
shown.map((section, index) => {
|
|
282
|
+
const cells = (
|
|
283
|
+
<div
|
|
284
|
+
key={section.id}
|
|
285
|
+
className={clsx('grid grid-cols-[repeat(auto-fill,minmax(28px,1fr))] gap-1', section.label !== null && 'px-1.5 pb-2')}
|
|
286
|
+
>
|
|
287
|
+
{section.icons.map(([icon, cellGlyph]) => (
|
|
288
|
+
<Tooltip key={icon} label={icon} name>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
role="radio"
|
|
292
|
+
aria-checked={value === icon}
|
|
293
|
+
tabIndex={icon === tabStop ? 0 : -1}
|
|
294
|
+
disabled={disabled}
|
|
295
|
+
className={clsx(
|
|
296
|
+
'flex h-7 items-center justify-center rounded-md hover:bg-surface-hover disabled:opacity-50',
|
|
297
|
+
value === icon ? 'bg-surface-active text-text' : 'text-text-muted'
|
|
298
|
+
)}
|
|
299
|
+
onFocus={() => setFocused(icon)}
|
|
300
|
+
onClick={() => onChoose(icon)}
|
|
301
|
+
>
|
|
302
|
+
<Icon icon={cellGlyph} size={16} />
|
|
303
|
+
</button>
|
|
304
|
+
</Tooltip>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
if (section.label === null) {
|
|
309
|
+
return cells;
|
|
310
|
+
}
|
|
311
|
+
const headingId = `${id}-group-${index}`;
|
|
312
|
+
return (
|
|
313
|
+
<div key={section.id} role="group" aria-labelledby={headingId}>
|
|
314
|
+
{/* A step lighter than the popover in the dark theme, where a darker band would read as a hole in it. */}
|
|
315
|
+
<SectionLabel
|
|
316
|
+
render={<div />}
|
|
317
|
+
data-icon-heading=""
|
|
318
|
+
className="sticky top-0 z-10 flex h-7 items-center gap-2 bg-surface px-2.5 data-stuck:shadow-[0_1px_0_var(--border)] dark:bg-surface-hover"
|
|
319
|
+
>
|
|
320
|
+
<span id={headingId} className="truncate">
|
|
321
|
+
{section.label}
|
|
322
|
+
</span>
|
|
323
|
+
<span className="ms-auto shrink-0">{formatNumber(section.icons.length)}</span>
|
|
324
|
+
</SectionLabel>
|
|
325
|
+
{cells}
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
})
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
{onClear && (
|
|
332
|
+
<div className="flex border-t border-border p-1">
|
|
333
|
+
<Button size="sm" disabled={disabled || value === null} onClick={onClear}>
|
|
334
|
+
<Icon icon={Ban} size={12} />
|
|
335
|
+
{t('iconPicker.none')}
|
|
336
|
+
</Button>
|
|
337
|
+
</div>
|
|
254
338
|
)}
|
|
255
339
|
</div>
|
|
256
340
|
);
|
package/src/Input.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentProps } from 'react';
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
2
|
import type { LucideIcon } from 'lucide-react';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
4
|
import { fieldControlProps, useFieldControl } from './field-context.ts';
|
|
@@ -11,34 +11,40 @@ export interface InputProps extends Omit<ComponentProps<'input'>, 'size'> {
|
|
|
11
11
|
mono?: boolean;
|
|
12
12
|
/* Drawn in front of the text, such as a magnifier on a search field. `className` then goes to the box around the icon and the input; `ref` stays on the input. */
|
|
13
13
|
icon?: LucideIcon;
|
|
14
|
+
/* Drawn after the text inside the field, such as a count of matches and an `IconButton` that clears it. `className` then goes to the box, as with `icon`. */
|
|
15
|
+
end?: ReactNode;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/* The one text input: one border and one focus ring at the one height or the compact one. */
|
|
17
|
-
export function Input({ size = 'md', mono = false, icon, className, id, ...props }: InputProps) {
|
|
19
|
+
export function Input({ size = 'md', mono = false, icon, end, className, id, ...props }: InputProps) {
|
|
18
20
|
const field = useFieldControl();
|
|
19
21
|
const small = size === 'sm';
|
|
20
22
|
const control = { ...fieldControlProps(field, id), ...props };
|
|
21
23
|
|
|
22
|
-
if (icon === undefined) {
|
|
24
|
+
if (icon === undefined && end === undefined) {
|
|
23
25
|
return <input {...control} className={clsx('field', small && 'field-sm', mono && 'font-mono text-code', className)} />;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
// The input fills the whole box and the icon lets clicks through, so a click on the icon still lands in the input.
|
|
27
29
|
return (
|
|
28
30
|
<span className={clsx('field relative flex p-0', small && 'field-sm', className)}>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
{icon !== undefined && (
|
|
32
|
+
<Icon
|
|
33
|
+
icon={icon}
|
|
34
|
+
size={small ? 12 : 14}
|
|
35
|
+
className={clsx('pointer-events-none absolute top-1/2 -translate-y-1/2 text-text-faint', small ? 'left-2' : 'left-2.5')}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
34
38
|
<input
|
|
35
39
|
{...control}
|
|
36
40
|
className={clsx(
|
|
37
41
|
'min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint',
|
|
38
|
-
small ? '
|
|
42
|
+
icon === undefined ? (small ? 'pl-2' : 'pl-2.5') : small ? 'pl-7' : 'pl-8',
|
|
43
|
+
end === undefined ? (small ? 'pr-2' : 'pr-2.5') : 'pr-1',
|
|
39
44
|
mono && 'font-mono text-code'
|
|
40
45
|
)}
|
|
41
46
|
/>
|
|
47
|
+
{end !== undefined && <span className={clsx('flex shrink-0 items-center gap-1', small ? 'pr-0.5' : 'pr-1')}>{end}</span>}
|
|
42
48
|
</span>
|
|
43
49
|
);
|
|
44
50
|
}
|
|
@@ -50,10 +56,12 @@ export interface TextAreaProps extends ComponentProps<'textarea'> {
|
|
|
50
56
|
resize?: 'none' | 'vertical';
|
|
51
57
|
/* A command or anything else read back letter by letter. */
|
|
52
58
|
mono?: boolean;
|
|
59
|
+
/* `bare` drops the border, the ground and the focus outline, for a field inside a card that draws all three around it. */
|
|
60
|
+
variant?: 'field' | 'bare';
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/* A field of a few lines: a commit message, the body of a pull request, a note. At least 64px tall; `rows` makes it taller. */
|
|
56
|
-
export function TextArea({ size = 'md', resize = 'none', mono = false, className, id, ...props }: TextAreaProps) {
|
|
64
|
+
export function TextArea({ size = 'md', resize = 'none', mono = false, variant = 'field', className, id, ...props }: TextAreaProps) {
|
|
57
65
|
const field = useFieldControl();
|
|
58
66
|
return (
|
|
59
67
|
<textarea
|
|
@@ -62,6 +70,7 @@ export function TextArea({ size = 'md', resize = 'none', mono = false, className
|
|
|
62
70
|
className={clsx(
|
|
63
71
|
'field h-auto min-h-16 py-1.5',
|
|
64
72
|
size === 'sm' && 'field-sm',
|
|
73
|
+
variant === 'bare' && 'field-bare',
|
|
65
74
|
resize === 'vertical' ? 'resize-y' : 'resize-none',
|
|
66
75
|
mono && 'font-mono text-code',
|
|
67
76
|
className
|
package/src/Segmented.tsx
CHANGED
|
@@ -69,7 +69,7 @@ export function Segmented<T extends string>({
|
|
|
69
69
|
<div
|
|
70
70
|
ref={ref}
|
|
71
71
|
className={clsx(
|
|
72
|
-
'flex bg-surface-sunken p-0.5 text-xs font-medium',
|
|
72
|
+
'flex bg-surface-sunken p-0.5 text-xs font-medium dark:bg-surface-idle',
|
|
73
73
|
sizing.track,
|
|
74
74
|
vertical ? 'w-full flex-col items-stretch gap-0.5' : clsx(sizing.row, 'w-fit items-center'),
|
|
75
75
|
className
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useState, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
import { splitFrameClass, splitHalfClass, type ButtonSize, type ButtonVariant } from './button-class.ts';
|
|
5
|
+
import { Icon } from './Icon.tsx';
|
|
6
|
+
import { MenuPopup, MenuRoot, MenuTrigger, type MenuPopupProps } from './menu/parts.tsx';
|
|
7
|
+
import { mergeRefs } from './merge-refs.ts';
|
|
8
|
+
import { Tooltip } from './Tooltip.tsx';
|
|
9
|
+
|
|
10
|
+
export interface SplitButtonProps extends Omit<ComponentProps<'button'>, 'ref'> {
|
|
11
|
+
variant?: ButtonVariant;
|
|
12
|
+
size?: ButtonSize;
|
|
13
|
+
/* The hint on the action half. */
|
|
14
|
+
tooltip?: ReactNode;
|
|
15
|
+
/* The rows of the menu the chevron opens: `Menu.Item`, `Menu.Separator` and the rest of a menu's parts. */
|
|
16
|
+
menu: ReactNode;
|
|
17
|
+
/* The accessible name of the chevron, and its hint unless `menuTooltip` says something else. */
|
|
18
|
+
menuLabel: string;
|
|
19
|
+
menuTooltip?: ReactNode;
|
|
20
|
+
menuDisabled?: boolean;
|
|
21
|
+
/* Where the menu lines up under the whole control. */
|
|
22
|
+
menuAlign?: MenuPopupProps['align'];
|
|
23
|
+
/* A width or a layout of the menu's own, such as `w-72`. */
|
|
24
|
+
menuClassName?: string;
|
|
25
|
+
/* On the box around both halves; every other prop goes to the action half. */
|
|
26
|
+
className?: string;
|
|
27
|
+
ref?: ComponentProps<'span'>['ref'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* A primary action and a chevron that opens a menu of related ones, drawn as one button with a short
|
|
32
|
+
* line between the halves. Each half is a button of its own, so each takes the focus, can be disabled
|
|
33
|
+
* and carries its own hint; the menu hangs under the whole control.
|
|
34
|
+
*/
|
|
35
|
+
export function SplitButton({
|
|
36
|
+
variant = 'secondary',
|
|
37
|
+
size = 'md',
|
|
38
|
+
tooltip,
|
|
39
|
+
menu,
|
|
40
|
+
menuLabel,
|
|
41
|
+
menuTooltip,
|
|
42
|
+
menuDisabled = false,
|
|
43
|
+
menuAlign = 'end',
|
|
44
|
+
menuClassName,
|
|
45
|
+
className,
|
|
46
|
+
ref,
|
|
47
|
+
type = 'button',
|
|
48
|
+
...props
|
|
49
|
+
}: SplitButtonProps) {
|
|
50
|
+
const [box, setBox] = useState<HTMLSpanElement | null>(null);
|
|
51
|
+
const action = <button type={type} className={splitHalfClass(variant, size, 'action')} {...props} />;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span ref={mergeRefs<HTMLSpanElement>(ref, setBox)} className={clsx(splitFrameClass(variant, size), className)}>
|
|
55
|
+
{tooltip === undefined ? action : <Tooltip label={tooltip}>{action}</Tooltip>}
|
|
56
|
+
<MenuRoot>
|
|
57
|
+
<Tooltip label={menuTooltip ?? menuLabel}>
|
|
58
|
+
<MenuTrigger
|
|
59
|
+
render={<button type="button" className={splitHalfClass(variant, size, 'menu')} />}
|
|
60
|
+
aria-label={menuLabel}
|
|
61
|
+
disabled={menuDisabled}
|
|
62
|
+
>
|
|
63
|
+
<Icon icon={ChevronDown} size={14} />
|
|
64
|
+
</MenuTrigger>
|
|
65
|
+
</Tooltip>
|
|
66
|
+
<MenuPopup anchor={box} align={menuAlign} className={menuClassName}>
|
|
67
|
+
{menu}
|
|
68
|
+
</MenuPopup>
|
|
69
|
+
</MenuRoot>
|
|
70
|
+
</span>
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/Stepper.tsx
CHANGED
|
@@ -36,7 +36,12 @@ export function Stepper({ value, onValueChange, min, max, step, decimals = place
|
|
|
36
36
|
onValueChange(Math.min(max, Math.max(min, next)));
|
|
37
37
|
};
|
|
38
38
|
return (
|
|
39
|
-
<ButtonGroup
|
|
39
|
+
<ButtonGroup
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={clsx('shrink-0 rounded-lg border border-field-border bg-field bg-clip-padding p-px', className)}
|
|
42
|
+
role="group"
|
|
43
|
+
aria-label={label}
|
|
44
|
+
>
|
|
40
45
|
<IconButton
|
|
41
46
|
icon={Minus}
|
|
42
47
|
size="sm"
|
package/src/Switch.tsx
CHANGED
|
@@ -23,7 +23,7 @@ export function Switch({ checked, onCheckedChange, label, disabled, className, r
|
|
|
23
23
|
disabled={disabled}
|
|
24
24
|
// Base UI renders the root as a `span`, which ignores its size outside a flex row unless it lays out as a box.
|
|
25
25
|
className={clsx(
|
|
26
|
-
'relative inline-flex 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
|
+
'relative inline-flex h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent dark:not-data-checked:bg-field dark:not-data-checked:inset-ring dark:not-data-checked:inset-ring-field-border data-disabled:opacity-50',
|
|
27
27
|
className
|
|
28
28
|
)}
|
|
29
29
|
>
|
package/src/Tile.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export function Tile({ icon, title, description, shortcut, primary = false, size
|
|
|
24
24
|
<button
|
|
25
25
|
type={type}
|
|
26
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',
|
|
27
|
+
'flex min-w-0 items-center border bg-surface text-left hover:bg-surface-hover-edged disabled:opacity-50 disabled:hover:bg-surface',
|
|
28
28
|
size === 'md' ? 'gap-3 rounded-xl p-3' : 'gap-2 rounded-lg px-2 py-1.5',
|
|
29
29
|
primary ? 'border-accent' : 'border-border',
|
|
30
30
|
className
|
|
@@ -34,7 +34,7 @@ export function Tile({ icon, title, description, shortcut, primary = false, size
|
|
|
34
34
|
<span
|
|
35
35
|
className={clsx(
|
|
36
36
|
'grid size-8 shrink-0 place-items-center rounded-lg',
|
|
37
|
-
primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted'
|
|
37
|
+
primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted dark:bg-field dark:inset-ring dark:inset-ring-field-border'
|
|
38
38
|
)}
|
|
39
39
|
>
|
|
40
40
|
{icon}
|