@adecore/ui 0.14.0 → 0.16.0-beta.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/Pill.js +14 -9
- package/dist/Switch.js +3 -1
- package/dist/format/index.d.ts +1 -1
- package/dist/format/index.js +1 -1
- package/dist/format/number.d.ts +2 -0
- package/dist/format/number.js +30 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/key-value-list/index.parts.d.ts +1 -0
- package/dist/key-value-list/index.parts.js +1 -0
- package/dist/key-value-list/parts.d.ts +13 -0
- package/dist/key-value-list/parts.js +33 -0
- package/dist/locales/en.json +4 -0
- package/dist/locales/nl.json +4 -0
- package/dist/tabs/index.parts.d.ts +1 -0
- package/dist/tabs/index.parts.js +1 -0
- package/dist/tabs/overflow.d.ts +1 -0
- package/dist/tabs/overflow.js +29 -0
- package/dist/tabs/parts.d.ts +24 -0
- package/dist/tabs/parts.js +127 -0
- package/dist/theme.css +3 -0
- package/package.json +1 -1
- package/src/Pill.tsx +14 -9
- package/src/Switch.tsx +2 -1
- package/src/format/index.ts +12 -1
- package/src/format/number.ts +36 -1
- package/src/index.ts +4 -0
- package/src/key-value-list/index.parts.ts +1 -0
- package/src/key-value-list/parts.tsx +55 -0
- package/src/locales/en.json +60 -56
- package/src/locales/nl.json +60 -56
- package/src/tabs/index.parts.ts +1 -0
- package/src/tabs/overflow.ts +29 -0
- package/src/tabs/parts.tsx +256 -0
- package/src/theme.css +3 -0
package/dist/Pill.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
/*
|
|
3
|
+
/*
|
|
4
|
+
* What the label says about the thing it names, drawn as its ground. Every ground is an alpha over
|
|
5
|
+
* whatever the pill stands on, the way a border is: in the dark theme it lifts a shade off the row
|
|
6
|
+
* under it, in the light theme it sinks one. A solid sunken ground read as a hole on a dark row.
|
|
7
|
+
*/
|
|
4
8
|
const TONES = {
|
|
5
|
-
muted: 'bg-
|
|
6
|
-
/*
|
|
7
|
-
raised: 'bg-
|
|
9
|
+
muted: 'bg-text/7 text-text-muted',
|
|
10
|
+
/* A step further off the ground, for a label that should stand out as a control. */
|
|
11
|
+
raised: 'bg-text/12 text-text-muted',
|
|
8
12
|
idle: 'bg-status-idle/15 text-status-idle',
|
|
9
13
|
needsYou: 'bg-status-needs-you/15 text-status-needs-you',
|
|
10
14
|
error: 'bg-status-error/15 text-status-error',
|
|
11
|
-
accent: 'bg-accent
|
|
15
|
+
accent: 'bg-accent/16 text-accent'
|
|
12
16
|
};
|
|
13
|
-
/*
|
|
17
|
+
/* The corners of a button, so a pill reads as a small label of the same family and not as a badge. A tag, at the end of a line of prose, is tighter and heavier. */
|
|
14
18
|
const SHAPES = {
|
|
15
|
-
pill: 'rounded-
|
|
19
|
+
pill: 'rounded-md px-2 py-0.5',
|
|
16
20
|
tag: 'rounded-md px-1.5 py-0.5 font-medium'
|
|
17
21
|
};
|
|
18
22
|
/* The small rounded label in a header, a sidebar row or a settings line: a count, a branch, a status. */
|
|
19
23
|
export function Pill({ icon, children, tone = 'muted', shape = 'pill', mono = false, onClick, pressed, disabled, className, ref }) {
|
|
20
|
-
|
|
24
|
+
// Never broken inside itself: in a row that wraps, a pill that does not fit moves to the next line whole.
|
|
25
|
+
const shared = clsx('inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-2xs', SHAPES[shape], TONES[tone], mono && 'font-mono', className);
|
|
21
26
|
if (!onClick) {
|
|
22
27
|
return (_jsxs("span", { ref: ref, className: shared, children: [icon, children] }));
|
|
23
28
|
}
|
|
24
|
-
return (_jsxs("button", { ref: ref, type: "button", "aria-pressed": pressed, disabled: disabled, className: clsx(shared, 'enabled:hover:text-text disabled:opacity-50 aria-pressed:bg-
|
|
29
|
+
return (_jsxs("button", { ref: ref, type: "button", "aria-pressed": pressed, disabled: disabled, className: clsx(shared, 'enabled:hover:text-text disabled:opacity-50 aria-pressed:bg-text/14 aria-pressed:text-text'), onClick: onClick, children: [icon, children] }));
|
|
25
30
|
}
|
package/dist/Switch.js
CHANGED
|
@@ -3,5 +3,7 @@ import clsx from 'clsx';
|
|
|
3
3
|
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
|
|
4
4
|
/* On or off, as a switch rather than a checkbox: a setting that takes effect the moment it flips. */
|
|
5
5
|
export function Switch({ checked, onCheckedChange, label, disabled, className, ref }) {
|
|
6
|
-
return (_jsx(BaseSwitch.Root, { ref: ref, checked: checked, onCheckedChange: (next) => onCheckedChange(next), "aria-label": label, disabled: disabled,
|
|
6
|
+
return (_jsx(BaseSwitch.Root, { ref: ref, checked: checked, onCheckedChange: (next) => onCheckedChange(next), "aria-label": label, disabled: disabled,
|
|
7
|
+
// Base UI renders the root as a `span`, which ignores its size outside a flex row unless it lays out as a box.
|
|
8
|
+
className: clsx('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', className), children: _jsx(BaseSwitch.Thumb, { className: "block h-4 w-4 rounded-full bg-surface-raised shadow-raised transition-transform data-checked:translate-x-4" }) }));
|
|
7
9
|
}
|
package/dist/format/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.ts';
|
|
2
2
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
|
|
3
3
|
export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
4
|
-
export { formatBytes, formatDecimal, formatMoney, formatNumber, formatPercent, formatTokens, formatUsdSignificant } from './number.ts';
|
|
4
|
+
export { formatBytes, formatDecimal, formatFixed, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.ts';
|
|
5
5
|
export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
|
|
6
6
|
export { localTimeZone } from './time-zone.ts';
|
package/dist/format/index.js
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
export { formatClock, formatDateTime, formatDay, formatDayClock, formatDayWithYear, formatHour, formatMoment, formatNumericDate, formatWeekdayClock, formatWeekdayDay, isSameDay } from './datetime.js';
|
|
3
3
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.js';
|
|
4
4
|
export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale } from './locale.js';
|
|
5
|
-
export { formatBytes, formatDecimal, formatMoney, formatNumber, formatPercent, formatTokens, formatUsdSignificant } from './number.js';
|
|
5
|
+
export { formatBytes, formatDecimal, formatFixed, formatMoney, formatNumber, formatNumeral, formatPercent, formatRounded, formatTokens, formatUsdSignificant } from './number.js';
|
|
6
6
|
export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.js';
|
|
7
7
|
export { localTimeZone } from './time-zone.js';
|
package/dist/format/number.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export declare const formatNumber: (value: number) => string;
|
|
2
2
|
export declare const formatDecimal: (value: number) => string;
|
|
3
3
|
export declare const formatFixed: (value: number, decimals: number) => string;
|
|
4
|
+
export declare const formatRounded: (value: number, decimals: number) => string;
|
|
5
|
+
export declare const formatNumeral: (numeral: string | number) => string;
|
|
4
6
|
export declare const formatPercent: (value: number) => string;
|
|
5
7
|
export declare const formatMoney: (value: number, currency: string, maximumFractionDigits?: number) => string;
|
|
6
8
|
export declare const formatUsdSignificant: (value: number) => string;
|
package/dist/format/number.js
CHANGED
|
@@ -7,14 +7,43 @@ export const formatNumber = (value) => numberFormatter(WHOLE).format(Math.round(
|
|
|
7
7
|
export const formatDecimal = (value) => numberFormatter(ONE_DECIMAL).format(value);
|
|
8
8
|
const fixedSpecs = new Map();
|
|
9
9
|
/* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
|
|
10
|
-
export const formatFixed = (value, decimals) =>
|
|
10
|
+
export const formatFixed = (value, decimals) => numberFormatter(fixedSpec(decimals)).format(value);
|
|
11
|
+
const fixedSpec = (decimals) => {
|
|
11
12
|
let spec = fixedSpecs.get(decimals);
|
|
12
13
|
if (spec === undefined) {
|
|
13
14
|
spec = { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
|
|
14
15
|
fixedSpecs.set(decimals, spec);
|
|
15
16
|
}
|
|
17
|
+
return spec;
|
|
18
|
+
};
|
|
19
|
+
const roundedSpecs = new Map();
|
|
20
|
+
/* At most `decimals` places, trailing zeros dropped, for a figure whose fraction may or may not be there: `39`, `1,95`. */
|
|
21
|
+
export const formatRounded = (value, decimals) => {
|
|
22
|
+
let spec = roundedSpecs.get(decimals);
|
|
23
|
+
if (spec === undefined) {
|
|
24
|
+
spec = { maximumFractionDigits: decimals };
|
|
25
|
+
roundedSpecs.set(decimals, spec);
|
|
26
|
+
}
|
|
16
27
|
return numberFormatter(spec).format(value);
|
|
17
28
|
};
|
|
29
|
+
/* Intl takes at most this many fraction digits. */
|
|
30
|
+
const MAX_FRACTION_DIGITS = 100;
|
|
31
|
+
const NUMERAL = /^[+-]?\d+(?:\.(\d+))?$/;
|
|
32
|
+
/*
|
|
33
|
+
* A numeral as a database writes it (`12900`, `-0.50`, `9007199254740993`), in the region's notation
|
|
34
|
+
* with every digit kept: a decimal or a big integer arrives as text, and a float would round it.
|
|
35
|
+
* Text that is no plain numeral, such as `NaN` or `1e999`, comes back as it is.
|
|
36
|
+
*/
|
|
37
|
+
export const formatNumeral = (numeral) => {
|
|
38
|
+
const text = typeof numeral === 'number' ? String(numeral) : numeral.trim();
|
|
39
|
+
const match = NUMERAL.exec(text);
|
|
40
|
+
if (match === null) {
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
const decimals = Math.min(match[1]?.length ?? 0, MAX_FRACTION_DIGITS);
|
|
44
|
+
// A string goes to Intl as it is, so it formats the exact decimal and not the nearest double.
|
|
45
|
+
return numberFormatter(fixedSpec(decimals)).format(text);
|
|
46
|
+
};
|
|
18
47
|
/* Percent as Activity Monitor writes it: a decimal under ten, where the difference still shows. */
|
|
19
48
|
export const formatPercent = (value) => `${value < 10 ? formatDecimal(value) : formatNumber(value)}%`;
|
|
20
49
|
/*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export * as ContextMenu from './context-menu/index.parts.ts';
|
|
2
2
|
export * as Dialog from './dialog/index.parts.ts';
|
|
3
|
+
export * as KeyValueList from './key-value-list/index.parts.ts';
|
|
3
4
|
export * as Menu from './menu/index.parts.ts';
|
|
4
5
|
export * as Popover from './popover/index.parts.ts';
|
|
5
6
|
export * as PreviewCard from './preview-card/index.parts.ts';
|
|
7
|
+
export * as Tabs from './tabs/index.parts.ts';
|
|
6
8
|
export type { DialogDescriptionProps, DialogFooterProps, DialogPopupProps, DialogTextProps, DialogTitleProps } from './dialog/parts.tsx';
|
|
7
9
|
export type { MenuCheckProps, MenuHintProps, MenuLabelProps, MenuPopupProps, MenuRowActionProps, MenuRowProps, MenuRowSubmenuTriggerProps } from './menu/parts.tsx';
|
|
10
|
+
export type { KeyValueListItemProps, KeyValueListNameProps, KeyValueListRootProps, KeyValueListValueProps } from './key-value-list/parts.tsx';
|
|
8
11
|
export type { PopoverPopupProps, PopupVariant, PreviewCardPopupProps } from './popover/parts.tsx';
|
|
12
|
+
export type { TabsCountProps, TabsListProps, TabsRootProps, TabsTabProps, TabsValue } from './tabs/parts.tsx';
|
|
9
13
|
export { AccentSwatches, type AccentSwatchesProps } from './AccentSwatches.tsx';
|
|
10
14
|
export { Banner, type BannerProps, type BannerTone } from './Banner.tsx';
|
|
11
15
|
export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button.tsx';
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export * as ContextMenu from './context-menu/index.parts.js';
|
|
6
6
|
export * as Dialog from './dialog/index.parts.js';
|
|
7
|
+
export * as KeyValueList from './key-value-list/index.parts.js';
|
|
7
8
|
export * as Menu from './menu/index.parts.js';
|
|
8
9
|
export * as Popover from './popover/index.parts.js';
|
|
9
10
|
export * as PreviewCard from './preview-card/index.parts.js';
|
|
11
|
+
export * as Tabs from './tabs/index.parts.js';
|
|
10
12
|
export { AccentSwatches } from './AccentSwatches.js';
|
|
11
13
|
export { Banner } from './Banner.js';
|
|
12
14
|
export { Button } from './Button.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KeyValueListItem as Item, KeyValueListName as Name, KeyValueListRoot as Root, KeyValueListValue as Value } from './parts.tsx';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KeyValueListItem as Item, KeyValueListName as Name, KeyValueListRoot as Root, KeyValueListValue as Value } from './parts.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
2
|
+
export type KeyValueListRootProps = useRender.ComponentProps<'dl'> & {
|
|
3
|
+
divided?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function KeyValueListRoot({ divided, render, className, ref, ...props }: KeyValueListRootProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
6
|
+
export type KeyValueListItemProps = useRender.ComponentProps<'div'>;
|
|
7
|
+
export declare function KeyValueListItem({ render, className, ref, ...props }: KeyValueListItemProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
8
|
+
export type KeyValueListNameProps = useRender.ComponentProps<'dt'>;
|
|
9
|
+
export declare function KeyValueListName({ render, className, ref, ...props }: KeyValueListNameProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
10
|
+
export type KeyValueListValueProps = useRender.ComponentProps<'dd'> & {
|
|
11
|
+
mono?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function KeyValueListValue({ mono, render, className, ref, ...props }: KeyValueListValueProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
3
|
+
/*
|
|
4
|
+
* The names take the width of the widest one, up to two fifths of the list, and a longer name wraps
|
|
5
|
+
* there. Every item is a subgrid of the list, so the names line up across rows and a hairline runs
|
|
6
|
+
* under both columns.
|
|
7
|
+
*/
|
|
8
|
+
export function KeyValueListRoot({ divided = false, render, className, ref, ...props }) {
|
|
9
|
+
return useRender({
|
|
10
|
+
render,
|
|
11
|
+
ref,
|
|
12
|
+
defaultTagName: 'dl',
|
|
13
|
+
props: {
|
|
14
|
+
...props,
|
|
15
|
+
className: clsx('grid min-w-0 grid-cols-[fit-content(40%)_minmax(0,1fr)] gap-x-3 text-xs', divided ? 'divide-y divide-border-soft *:py-1.5' : 'gap-y-1', className)
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function KeyValueListItem({ render, className, ref, ...props }) {
|
|
20
|
+
return useRender({ render, ref, defaultTagName: 'div', props: { ...props, className: clsx('col-span-2 grid grid-cols-subgrid', className) } });
|
|
21
|
+
}
|
|
22
|
+
export function KeyValueListName({ render, className, ref, ...props }) {
|
|
23
|
+
return useRender({ render, ref, defaultTagName: 'dt', props: { ...props, className: clsx('min-w-0 break-words text-text-faint', className) } });
|
|
24
|
+
}
|
|
25
|
+
/* Selectable, unlike the interface around it, so a long value can be copied. */
|
|
26
|
+
export function KeyValueListValue({ mono = false, render, className, ref, ...props }) {
|
|
27
|
+
return useRender({
|
|
28
|
+
render,
|
|
29
|
+
ref,
|
|
30
|
+
defaultTagName: 'dd',
|
|
31
|
+
props: { ...props, className: clsx('min-w-0 break-words text-text select-text', mono && 'font-mono text-code', className) }
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/locales/en.json
CHANGED
package/dist/locales/nl.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TabsCount as Count, TabsList as List, TabsPanel as Panel, TabsRoot as Root, TabsTab as Tab } from './parts.tsx';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TabsCount as Count, TabsList as List, TabsPanel as Panel, TabsRoot as Root, TabsTab as Tab } from './parts.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const shownTabs: (widths: readonly number[], room: number, gap: number, more: number, picked: number) => number[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The tabs a strip shows, by index, given the width of every tab, the room the strip has, the gap
|
|
3
|
+
* between two tabs and the width of the button that opens the rest. Everything fits, or the tabs fit
|
|
4
|
+
* from the start up to the button. The picked tab is never left to the menu: it takes the place of
|
|
5
|
+
* the last ones that no longer fit beside it.
|
|
6
|
+
*/
|
|
7
|
+
export const shownTabs = (widths, room, gap, more, picked) => {
|
|
8
|
+
const span = (indices) => indices.reduce((sum, index) => sum + widths[index], 0) + gap * Math.max(0, indices.length - 1);
|
|
9
|
+
const all = widths.map((_, index) => index);
|
|
10
|
+
// Half a pixel of slack, since a measured width is fractional and the room is not.
|
|
11
|
+
if (span(all) <= room + 0.5) {
|
|
12
|
+
return all;
|
|
13
|
+
}
|
|
14
|
+
const limit = room - more - gap + 0.5;
|
|
15
|
+
const shown = [];
|
|
16
|
+
for (const index of all) {
|
|
17
|
+
if (span([...shown, index]) > limit) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
shown.push(index);
|
|
21
|
+
}
|
|
22
|
+
if (picked < 0 || picked >= widths.length || shown.includes(picked)) {
|
|
23
|
+
return shown;
|
|
24
|
+
}
|
|
25
|
+
while (shown.length > 0 && span([...shown, picked]) > limit) {
|
|
26
|
+
shown.pop();
|
|
27
|
+
}
|
|
28
|
+
return [...shown, picked];
|
|
29
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { Tabs as BaseTabs } from '@base-ui-components/react/tabs';
|
|
3
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
4
|
+
export type TabsValue = BaseTabs.Tab.Value;
|
|
5
|
+
export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueChange'> & {
|
|
6
|
+
onValueChange?(value: TabsValue): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function TabsRoot({ value, defaultValue, onValueChange, ...props }: TabsRootProps): import("react").JSX.Element;
|
|
9
|
+
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'className'> & {
|
|
10
|
+
className?: string;
|
|
11
|
+
end?: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
export declare function TabsList({ className, children, end, ...props }: TabsListProps): import("react").JSX.Element;
|
|
14
|
+
type TabProps = ComponentProps<typeof BaseTabs.Tab>;
|
|
15
|
+
export type TabsTabProps = TabProps & {
|
|
16
|
+
onClose?(): void;
|
|
17
|
+
};
|
|
18
|
+
export declare function TabsTab({ className, onClose, children, ...props }: TabsTabProps): import("react").JSX.Element;
|
|
19
|
+
export declare const TabsPanel: import("react").ForwardRefExoticComponent<import("@base-ui-components/react/tabs").TabsPanelProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
20
|
+
export type TabsCountProps = Omit<useRender.ComponentProps<'span'>, 'children'> & {
|
|
21
|
+
value: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function TabsCount({ value, render, className, ref, ...props }: TabsCountProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Children, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import { ChevronDown, X } from 'lucide-react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Tabs as BaseTabs } from '@base-ui-components/react/tabs';
|
|
7
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
8
|
+
import { withClass } from '../class-name.js';
|
|
9
|
+
import { formatNumber } from '../format/number.js';
|
|
10
|
+
import { Icon } from '../Icon.js';
|
|
11
|
+
import { IconButton } from '../IconButton.js';
|
|
12
|
+
import { MenuItem, MenuPopup, MenuRoot, MenuTrigger } from '../menu/parts.js';
|
|
13
|
+
import { Pill } from '../Pill.js';
|
|
14
|
+
import { shownTabs } from './overflow.js';
|
|
15
|
+
const SelectionContext = createContext({ value: undefined, select: () => { } });
|
|
16
|
+
/* Whether a part is drawn in the menu of tabs that do not fit, where a count goes to the end of its row. */
|
|
17
|
+
const InMenuContext = createContext(false);
|
|
18
|
+
/* The underline sits over the strip's border rather than above it, without a negative margin that would make the strip scroll. */
|
|
19
|
+
const TAB = 'relative flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap text-xs font-medium text-text-muted hover:text-text data-[active]:text-text data-[disabled]:opacity-50 after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 data-[active]:after:bg-text';
|
|
20
|
+
/* The button that opens the tabs that do not fit reads as one more tab, at the very end of the strip. */
|
|
21
|
+
const MORE = clsx(TAB, 'ml-auto data-[popup-open]:text-text');
|
|
22
|
+
/* Holds the picked tab itself, uncontrolled or not, so a tab picked from the menu of the strip changes it too. */
|
|
23
|
+
export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }) {
|
|
24
|
+
const [own, setOwn] = useState(defaultValue);
|
|
25
|
+
const current = value === undefined ? own : value;
|
|
26
|
+
const select = (next) => {
|
|
27
|
+
if (value === undefined) {
|
|
28
|
+
setOwn(next);
|
|
29
|
+
}
|
|
30
|
+
onValueChange?.(next);
|
|
31
|
+
};
|
|
32
|
+
return (_jsx(SelectionContext, { value: { value: current, select }, children: _jsx(BaseTabs.Root, { value: current, onValueChange: (next) => select(next), ...props }) }));
|
|
33
|
+
}
|
|
34
|
+
const sameIndices = (left, right) => left !== null && left.length === right.length && left.every((index, at) => index === right[at]);
|
|
35
|
+
/*
|
|
36
|
+
* The tabs that fit, and a menu at the end of the strip with the ones that do not. Every tab is measured
|
|
37
|
+
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
38
|
+
* since the menu repeats what a tab holds.
|
|
39
|
+
*/
|
|
40
|
+
export function TabsList({ className, children, end, ...props }) {
|
|
41
|
+
const { t } = useTranslation('ui');
|
|
42
|
+
const { value, select } = useContext(SelectionContext);
|
|
43
|
+
const strip = useRef(null);
|
|
44
|
+
const measure = useRef(null);
|
|
45
|
+
const tail = useRef(null);
|
|
46
|
+
// Null until the first measure, which runs before the first paint.
|
|
47
|
+
const [shown, setShown] = useState(null);
|
|
48
|
+
const tabs = Children.toArray(children).filter((child) => isValidElement(child) && child.type === TabsTab);
|
|
49
|
+
const picked = tabs.findIndex((tab) => tab.props.value === value);
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
const host = strip.current;
|
|
52
|
+
const row = measure.current;
|
|
53
|
+
if (!host || !row) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const fit = () => {
|
|
57
|
+
const style = getComputedStyle(host);
|
|
58
|
+
const gap = parseFloat(style.columnGap) || 0;
|
|
59
|
+
const taken = tail.current === null ? 0 : tail.current.getBoundingClientRect().width + gap;
|
|
60
|
+
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - taken;
|
|
61
|
+
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
62
|
+
const more = cells.pop() ?? 0;
|
|
63
|
+
const next = shownTabs(cells, room, parseFloat(getComputedStyle(row).columnGap) || 0, more, picked);
|
|
64
|
+
setShown((current) => (sameIndices(current, next) ? current : next));
|
|
65
|
+
};
|
|
66
|
+
fit();
|
|
67
|
+
const observer = new ResizeObserver(fit);
|
|
68
|
+
observer.observe(host);
|
|
69
|
+
observer.observe(row);
|
|
70
|
+
if (tail.current !== null) {
|
|
71
|
+
observer.observe(tail.current);
|
|
72
|
+
}
|
|
73
|
+
return () => observer.disconnect();
|
|
74
|
+
});
|
|
75
|
+
const visible = shown === null ? tabs : shown.map((index) => tabs[index]);
|
|
76
|
+
const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
|
|
77
|
+
return (_jsxs("div", { ref: strip, className: clsx('relative flex items-center gap-4 border-b border-border', className), children: [_jsx(BaseTabs.List, { className: "flex min-w-0 items-end gap-4", ...props, children: visible }), hidden.length > 0 && (_jsxs(MenuRoot, { children: [_jsxs(MenuTrigger, { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] }), _jsx(MenuPopup, { align: "end", className: "min-w-48", children: _jsx(InMenuContext, { value: true, children: hidden.map((tab) => (_jsx(MenuItem, { disabled: tab.props.disabled, onClick: () => select(tab.props.value), children: tab.props.children }, tab.key))) }) })] })), end !== undefined && end !== null && (_jsx("div", { ref: tail, className: clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto'), children: end })), _jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex gap-4", children: [tabs.map((tab) => (_jsxs("span", { className: TAB, children: [tab.props.children, tab.props.onClose !== undefined && _jsx("span", { className: "size-5 shrink-0" })] }, tab.key))), _jsxs("span", { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] })] })] }));
|
|
78
|
+
}
|
|
79
|
+
/* The close button stays out of the tab order, so a closable tab is still one stop; the keys close it from the tab itself. */
|
|
80
|
+
export function TabsTab({ className, onClose, children, ...props }) {
|
|
81
|
+
const { t } = useTranslation('ui');
|
|
82
|
+
if (onClose === undefined) {
|
|
83
|
+
return (_jsx(BaseTabs.Tab, { className: withClass(TAB, className), ...props, children: children }));
|
|
84
|
+
}
|
|
85
|
+
const { onKeyDown, onAuxClick, onMouseDown, ...rest } = props;
|
|
86
|
+
const handleKeyDown = (e) => {
|
|
87
|
+
onKeyDown?.(e);
|
|
88
|
+
if (!e.defaultPrevented && e.target === e.currentTarget && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
onClose();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
// A middle press starts autoscroll on some platforms; the release is what closes.
|
|
94
|
+
const handleMouseDown = (e) => {
|
|
95
|
+
onMouseDown?.(e);
|
|
96
|
+
if (e.button === 1) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const handleAuxClick = (e) => {
|
|
101
|
+
onAuxClick?.(e);
|
|
102
|
+
if (!e.defaultPrevented && e.button === 1) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
onClose();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
return (_jsxs(BaseTabs.Tab, { nativeButton: false, render: _jsx("div", {}), className: withClass(TAB, className), onKeyDown: handleKeyDown, onMouseDown: handleMouseDown, onAuxClick: handleAuxClick, ...rest, children: [children, _jsx(IconButton, { icon: X, size: "2xs", label: t('tabs.close'), tabIndex: -1, onClick: (e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
onClose();
|
|
110
|
+
} })] }));
|
|
111
|
+
}
|
|
112
|
+
export const TabsPanel = BaseTabs.Panel;
|
|
113
|
+
/* How much a tab holds, after its label. Nothing at zero: an empty view needs no number to say so. */
|
|
114
|
+
export function TabsCount({ value, render, className, ref, ...props }) {
|
|
115
|
+
const inMenu = useContext(InMenuContext);
|
|
116
|
+
const element = useRender({
|
|
117
|
+
render,
|
|
118
|
+
ref,
|
|
119
|
+
defaultTagName: 'span',
|
|
120
|
+
props: {
|
|
121
|
+
...props,
|
|
122
|
+
children: _jsx(Pill, { className: "tabular-nums", children: formatNumber(value) }),
|
|
123
|
+
className: clsx('inline-flex shrink-0', inMenu && 'ml-auto', className)
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return value === 0 ? null : element;
|
|
127
|
+
}
|
package/dist/theme.css
CHANGED
|
@@ -154,6 +154,9 @@
|
|
|
154
154
|
/* No default palette: a component reaches only the semantic colors below. */
|
|
155
155
|
--color-*: initial;
|
|
156
156
|
--spacing: round(0.25rem, 1px);
|
|
157
|
+
/* Only for a label that rides in a row of `xs`, such as a pill: 12px, the smallest the theme sets. */
|
|
158
|
+
--text-2xs: round(0.8rem, 1px);
|
|
159
|
+
--text-2xs--line-height: round(1.25rem, 1px);
|
|
157
160
|
--text-xs: round(0.875rem, 1px);
|
|
158
161
|
--text-xs--line-height: round(1.25rem, 1px);
|
|
159
162
|
--text-sm: round(0.9375rem, 1px);
|
package/package.json
CHANGED
package/src/Pill.tsx
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { ReactNode, Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
|
|
4
|
-
/*
|
|
4
|
+
/*
|
|
5
|
+
* What the label says about the thing it names, drawn as its ground. Every ground is an alpha over
|
|
6
|
+
* whatever the pill stands on, the way a border is: in the dark theme it lifts a shade off the row
|
|
7
|
+
* under it, in the light theme it sinks one. A solid sunken ground read as a hole on a dark row.
|
|
8
|
+
*/
|
|
5
9
|
const TONES = {
|
|
6
|
-
muted: 'bg-
|
|
7
|
-
/*
|
|
8
|
-
raised: 'bg-
|
|
10
|
+
muted: 'bg-text/7 text-text-muted',
|
|
11
|
+
/* A step further off the ground, for a label that should stand out as a control. */
|
|
12
|
+
raised: 'bg-text/12 text-text-muted',
|
|
9
13
|
idle: 'bg-status-idle/15 text-status-idle',
|
|
10
14
|
needsYou: 'bg-status-needs-you/15 text-status-needs-you',
|
|
11
15
|
error: 'bg-status-error/15 text-status-error',
|
|
12
|
-
accent: 'bg-accent
|
|
16
|
+
accent: 'bg-accent/16 text-accent'
|
|
13
17
|
};
|
|
14
18
|
|
|
15
|
-
/*
|
|
19
|
+
/* The corners of a button, so a pill reads as a small label of the same family and not as a badge. A tag, at the end of a line of prose, is tighter and heavier. */
|
|
16
20
|
const SHAPES = {
|
|
17
|
-
pill: 'rounded-
|
|
21
|
+
pill: 'rounded-md px-2 py-0.5',
|
|
18
22
|
tag: 'rounded-md px-1.5 py-0.5 font-medium'
|
|
19
23
|
};
|
|
20
24
|
|
|
@@ -36,7 +40,8 @@ export interface PillProps {
|
|
|
36
40
|
|
|
37
41
|
/* The small rounded label in a header, a sidebar row or a settings line: a count, a branch, a status. */
|
|
38
42
|
export function Pill({ icon, children, tone = 'muted', shape = 'pill', mono = false, onClick, pressed, disabled, className, ref }: PillProps) {
|
|
39
|
-
|
|
43
|
+
// Never broken inside itself: in a row that wraps, a pill that does not fit moves to the next line whole.
|
|
44
|
+
const shared = clsx('inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-2xs', SHAPES[shape], TONES[tone], mono && 'font-mono', className);
|
|
40
45
|
if (!onClick) {
|
|
41
46
|
return (
|
|
42
47
|
<span ref={ref} className={shared}>
|
|
@@ -51,7 +56,7 @@ export function Pill({ icon, children, tone = 'muted', shape = 'pill', mono = fa
|
|
|
51
56
|
type="button"
|
|
52
57
|
aria-pressed={pressed}
|
|
53
58
|
disabled={disabled}
|
|
54
|
-
className={clsx(shared, 'enabled:hover:text-text disabled:opacity-50 aria-pressed:bg-
|
|
59
|
+
className={clsx(shared, 'enabled:hover:text-text disabled:opacity-50 aria-pressed:bg-text/14 aria-pressed:text-text')}
|
|
55
60
|
onClick={onClick}
|
|
56
61
|
>
|
|
57
62
|
{icon}
|
package/src/Switch.tsx
CHANGED
|
@@ -21,8 +21,9 @@ export function Switch({ checked, onCheckedChange, label, disabled, className, r
|
|
|
21
21
|
onCheckedChange={(next) => onCheckedChange(next)}
|
|
22
22
|
aria-label={label}
|
|
23
23
|
disabled={disabled}
|
|
24
|
+
// Base UI renders the root as a `span`, which ignores its size outside a flex row unless it lays out as a box.
|
|
24
25
|
className={clsx(
|
|
25
|
-
'relative h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent data-disabled:opacity-50',
|
|
26
|
+
'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
27
|
className
|
|
27
28
|
)}
|
|
28
29
|
>
|
package/src/format/index.ts
CHANGED
|
@@ -15,6 +15,17 @@ export {
|
|
|
15
15
|
} from './datetime.ts';
|
|
16
16
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort } from './duration.ts';
|
|
17
17
|
export { FALLBACK_LOCALE, formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
18
|
-
export {
|
|
18
|
+
export {
|
|
19
|
+
formatBytes,
|
|
20
|
+
formatDecimal,
|
|
21
|
+
formatFixed,
|
|
22
|
+
formatMoney,
|
|
23
|
+
formatNumber,
|
|
24
|
+
formatNumeral,
|
|
25
|
+
formatPercent,
|
|
26
|
+
formatRounded,
|
|
27
|
+
formatTokens,
|
|
28
|
+
formatUsdSignificant
|
|
29
|
+
} from './number.ts';
|
|
19
30
|
export { FORMAT_LANGUAGE, FORMAT_REGIONS, FORMAT_REGION_CHOICES, FORMAT_SYSTEM, formatRegionFrom, regionName } from './regions.ts';
|
|
20
31
|
export { localTimeZone } from './time-zone.ts';
|
package/src/format/number.ts
CHANGED
|
@@ -12,15 +12,50 @@ export const formatDecimal = (value: number): string => numberFormatter(ONE_DECI
|
|
|
12
12
|
const fixedSpecs = new Map<number, Intl.NumberFormatOptions>();
|
|
13
13
|
|
|
14
14
|
/* Always `decimals` places, so values that follow each other keep one width: `1,0` and `1,5`, never `1` and `1,5`. */
|
|
15
|
-
export const formatFixed = (value: number, decimals: number): string =>
|
|
15
|
+
export const formatFixed = (value: number, decimals: number): string => numberFormatter(fixedSpec(decimals)).format(value);
|
|
16
|
+
|
|
17
|
+
const fixedSpec = (decimals: number): Intl.NumberFormatOptions => {
|
|
16
18
|
let spec = fixedSpecs.get(decimals);
|
|
17
19
|
if (spec === undefined) {
|
|
18
20
|
spec = { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
|
|
19
21
|
fixedSpecs.set(decimals, spec);
|
|
20
22
|
}
|
|
23
|
+
return spec;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const roundedSpecs = new Map<number, Intl.NumberFormatOptions>();
|
|
27
|
+
|
|
28
|
+
/* At most `decimals` places, trailing zeros dropped, for a figure whose fraction may or may not be there: `39`, `1,95`. */
|
|
29
|
+
export const formatRounded = (value: number, decimals: number): string => {
|
|
30
|
+
let spec = roundedSpecs.get(decimals);
|
|
31
|
+
if (spec === undefined) {
|
|
32
|
+
spec = { maximumFractionDigits: decimals };
|
|
33
|
+
roundedSpecs.set(decimals, spec);
|
|
34
|
+
}
|
|
21
35
|
return numberFormatter(spec).format(value);
|
|
22
36
|
};
|
|
23
37
|
|
|
38
|
+
/* Intl takes at most this many fraction digits. */
|
|
39
|
+
const MAX_FRACTION_DIGITS = 100;
|
|
40
|
+
|
|
41
|
+
const NUMERAL = /^[+-]?\d+(?:\.(\d+))?$/;
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* A numeral as a database writes it (`12900`, `-0.50`, `9007199254740993`), in the region's notation
|
|
45
|
+
* with every digit kept: a decimal or a big integer arrives as text, and a float would round it.
|
|
46
|
+
* Text that is no plain numeral, such as `NaN` or `1e999`, comes back as it is.
|
|
47
|
+
*/
|
|
48
|
+
export const formatNumeral = (numeral: string | number): string => {
|
|
49
|
+
const text = typeof numeral === 'number' ? String(numeral) : numeral.trim();
|
|
50
|
+
const match = NUMERAL.exec(text);
|
|
51
|
+
if (match === null) {
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
const decimals = Math.min(match[1]?.length ?? 0, MAX_FRACTION_DIGITS);
|
|
55
|
+
// A string goes to Intl as it is, so it formats the exact decimal and not the nearest double.
|
|
56
|
+
return numberFormatter(fixedSpec(decimals)).format(text as Intl.StringNumericLiteral);
|
|
57
|
+
};
|
|
58
|
+
|
|
24
59
|
/* Percent as Activity Monitor writes it: a decimal under ten, where the difference still shows. */
|
|
25
60
|
export const formatPercent = (value: number): string => `${value < 10 ? formatDecimal(value) : formatNumber(value)}%`;
|
|
26
61
|
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
export * as ContextMenu from './context-menu/index.parts.ts';
|
|
7
7
|
export * as Dialog from './dialog/index.parts.ts';
|
|
8
|
+
export * as KeyValueList from './key-value-list/index.parts.ts';
|
|
8
9
|
export * as Menu from './menu/index.parts.ts';
|
|
9
10
|
export * as Popover from './popover/index.parts.ts';
|
|
10
11
|
export * as PreviewCard from './preview-card/index.parts.ts';
|
|
12
|
+
export * as Tabs from './tabs/index.parts.ts';
|
|
11
13
|
|
|
12
14
|
export type { DialogDescriptionProps, DialogFooterProps, DialogPopupProps, DialogTextProps, DialogTitleProps } from './dialog/parts.tsx';
|
|
13
15
|
export type {
|
|
@@ -19,7 +21,9 @@ export type {
|
|
|
19
21
|
MenuRowProps,
|
|
20
22
|
MenuRowSubmenuTriggerProps
|
|
21
23
|
} from './menu/parts.tsx';
|
|
24
|
+
export type { KeyValueListItemProps, KeyValueListNameProps, KeyValueListRootProps, KeyValueListValueProps } from './key-value-list/parts.tsx';
|
|
22
25
|
export type { PopoverPopupProps, PopupVariant, PreviewCardPopupProps } from './popover/parts.tsx';
|
|
26
|
+
export type { TabsCountProps, TabsListProps, TabsRootProps, TabsTabProps, TabsValue } from './tabs/parts.tsx';
|
|
23
27
|
|
|
24
28
|
export { AccentSwatches, type AccentSwatchesProps } from './AccentSwatches.tsx';
|
|
25
29
|
export { Banner, type BannerProps, type BannerTone } from './Banner.tsx';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KeyValueListItem as Item, KeyValueListName as Name, KeyValueListRoot as Root, KeyValueListValue as Value } from './parts.tsx';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
3
|
+
|
|
4
|
+
export type KeyValueListRootProps = useRender.ComponentProps<'dl'> & {
|
|
5
|
+
/* A hairline between rows, for a long list such as the headers of a message. Without it the rows sit close, for a few facts in a card. */
|
|
6
|
+
divided?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The names take the width of the widest one, up to two fifths of the list, and a longer name wraps
|
|
11
|
+
* there. Every item is a subgrid of the list, so the names line up across rows and a hairline runs
|
|
12
|
+
* under both columns.
|
|
13
|
+
*/
|
|
14
|
+
export function KeyValueListRoot({ divided = false, render, className, ref, ...props }: KeyValueListRootProps) {
|
|
15
|
+
return useRender({
|
|
16
|
+
render,
|
|
17
|
+
ref,
|
|
18
|
+
defaultTagName: 'dl',
|
|
19
|
+
props: {
|
|
20
|
+
...props,
|
|
21
|
+
className: clsx(
|
|
22
|
+
'grid min-w-0 grid-cols-[fit-content(40%)_minmax(0,1fr)] gap-x-3 text-xs',
|
|
23
|
+
divided ? 'divide-y divide-border-soft *:py-1.5' : 'gap-y-1',
|
|
24
|
+
className
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type KeyValueListItemProps = useRender.ComponentProps<'div'>;
|
|
31
|
+
|
|
32
|
+
export function KeyValueListItem({ render, className, ref, ...props }: KeyValueListItemProps) {
|
|
33
|
+
return useRender({ render, ref, defaultTagName: 'div', props: { ...props, className: clsx('col-span-2 grid grid-cols-subgrid', className) } });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type KeyValueListNameProps = useRender.ComponentProps<'dt'>;
|
|
37
|
+
|
|
38
|
+
export function KeyValueListName({ render, className, ref, ...props }: KeyValueListNameProps) {
|
|
39
|
+
return useRender({ render, ref, defaultTagName: 'dt', props: { ...props, className: clsx('min-w-0 break-words text-text-faint', className) } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type KeyValueListValueProps = useRender.ComponentProps<'dd'> & {
|
|
43
|
+
/* A value read back letter by letter, such as a header, an id or a path. */
|
|
44
|
+
mono?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/* Selectable, unlike the interface around it, so a long value can be copied. */
|
|
48
|
+
export function KeyValueListValue({ mono = false, render, className, ref, ...props }: KeyValueListValueProps) {
|
|
49
|
+
return useRender({
|
|
50
|
+
render,
|
|
51
|
+
ref,
|
|
52
|
+
defaultTagName: 'dd',
|
|
53
|
+
props: { ...props, className: clsx('min-w-0 break-words text-text select-text', mono && 'font-mono text-code', className) }
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/locales/en.json
CHANGED
|
@@ -1,59 +1,63 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"action": {
|
|
3
|
+
"cancel": "Cancel",
|
|
4
|
+
"copy": "Copy",
|
|
5
|
+
"copyError": "Copy error",
|
|
6
|
+
"dismiss": "Dismiss",
|
|
7
|
+
"reload": "Reload",
|
|
8
|
+
"retry": "Try again",
|
|
9
|
+
"select": "Select",
|
|
10
|
+
"selectAll": "Select all"
|
|
11
|
+
},
|
|
12
|
+
"accent": {
|
|
13
|
+
"more": "More colors"
|
|
14
|
+
},
|
|
15
|
+
"settings": {
|
|
16
|
+
"title": "Settings",
|
|
17
|
+
"section": "Settings section",
|
|
18
|
+
"sections": "Settings sections",
|
|
19
|
+
"close": "Close settings",
|
|
20
|
+
"failed": "This settings page failed to render",
|
|
21
|
+
"search": {
|
|
22
|
+
"placeholder": "Search settings",
|
|
23
|
+
"clear": "Clear search",
|
|
24
|
+
"none": "Nothing matches \"{{query}}\".",
|
|
25
|
+
"results": "Search results",
|
|
26
|
+
"pane": "Section"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"controls": {
|
|
30
|
+
"stepper": {
|
|
31
|
+
"smaller": "Smaller",
|
|
32
|
+
"larger": "Larger",
|
|
33
|
+
"smallerFor": "{{label}}: smaller",
|
|
34
|
+
"largerFor": "{{label}}: larger"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"iconPicker": {
|
|
38
|
+
"label": "Icon",
|
|
39
|
+
"none": "No icon",
|
|
40
|
+
"search": "Find an icon",
|
|
41
|
+
"clear": "Clear search",
|
|
42
|
+
"noMatch": "No icon matches “{{query}}”"
|
|
43
|
+
},
|
|
44
|
+
"zoom": {
|
|
45
|
+
"out": "Zoom out",
|
|
46
|
+
"in": "Zoom in",
|
|
47
|
+
"presets": "Zoom presets",
|
|
48
|
+
"fit": "Zoom to fit",
|
|
49
|
+
"fitEverything": "Fit everything"
|
|
50
|
+
},
|
|
51
|
+
"waveform": {
|
|
52
|
+
"position": "{{time}} of {{duration}}"
|
|
53
|
+
},
|
|
54
|
+
"projectSwitcher": {
|
|
55
|
+
"label": "Projects",
|
|
56
|
+
"recent": "Recent projects",
|
|
57
|
+
"actionsFor": "Actions for {{name}}"
|
|
58
|
+
},
|
|
59
|
+
"tabs": {
|
|
60
|
+
"more": "More",
|
|
61
|
+
"close": "Close tab"
|
|
27
62
|
}
|
|
28
|
-
},
|
|
29
|
-
"controls": {
|
|
30
|
-
"stepper": {
|
|
31
|
-
"smaller": "Smaller",
|
|
32
|
-
"larger": "Larger",
|
|
33
|
-
"smallerFor": "{{label}}: smaller",
|
|
34
|
-
"largerFor": "{{label}}: larger"
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
"iconPicker": {
|
|
38
|
-
"label": "Icon",
|
|
39
|
-
"none": "No icon",
|
|
40
|
-
"search": "Find an icon",
|
|
41
|
-
"clear": "Clear search",
|
|
42
|
-
"noMatch": "No icon matches “{{query}}”"
|
|
43
|
-
},
|
|
44
|
-
"zoom": {
|
|
45
|
-
"out": "Zoom out",
|
|
46
|
-
"in": "Zoom in",
|
|
47
|
-
"presets": "Zoom presets",
|
|
48
|
-
"fit": "Zoom to fit",
|
|
49
|
-
"fitEverything": "Fit everything"
|
|
50
|
-
},
|
|
51
|
-
"waveform": {
|
|
52
|
-
"position": "{{time}} of {{duration}}"
|
|
53
|
-
},
|
|
54
|
-
"projectSwitcher": {
|
|
55
|
-
"label": "Projects",
|
|
56
|
-
"recent": "Recent projects",
|
|
57
|
-
"actionsFor": "Actions for {{name}}"
|
|
58
|
-
}
|
|
59
63
|
}
|
package/src/locales/nl.json
CHANGED
|
@@ -1,59 +1,63 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"action": {
|
|
3
|
+
"cancel": "Annuleren",
|
|
4
|
+
"copy": "Kopiëren",
|
|
5
|
+
"copyError": "Fout kopiëren",
|
|
6
|
+
"dismiss": "Sluiten",
|
|
7
|
+
"reload": "Herladen",
|
|
8
|
+
"retry": "Opnieuw proberen",
|
|
9
|
+
"select": "Selecteren",
|
|
10
|
+
"selectAll": "Alles selecteren"
|
|
11
|
+
},
|
|
12
|
+
"accent": {
|
|
13
|
+
"more": "Meer kleuren"
|
|
14
|
+
},
|
|
15
|
+
"settings": {
|
|
16
|
+
"title": "Instellingen",
|
|
17
|
+
"section": "Onderdeel van de instellingen",
|
|
18
|
+
"sections": "Onderdelen van de instellingen",
|
|
19
|
+
"close": "Instellingen sluiten",
|
|
20
|
+
"failed": "Deze instellingenpagina kon niet worden getekend",
|
|
21
|
+
"search": {
|
|
22
|
+
"placeholder": "Instellingen zoeken",
|
|
23
|
+
"clear": "Zoekopdracht wissen",
|
|
24
|
+
"none": "Niets komt overeen met \"{{query}}\".",
|
|
25
|
+
"results": "Zoekresultaten",
|
|
26
|
+
"pane": "Onderdeel"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"controls": {
|
|
30
|
+
"stepper": {
|
|
31
|
+
"smaller": "Kleiner",
|
|
32
|
+
"larger": "Groter",
|
|
33
|
+
"smallerFor": "{{label}}: kleiner",
|
|
34
|
+
"largerFor": "{{label}}: groter"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"iconPicker": {
|
|
38
|
+
"label": "Icoon",
|
|
39
|
+
"none": "Geen icoon",
|
|
40
|
+
"search": "Zoek een icoon",
|
|
41
|
+
"clear": "Zoekopdracht wissen",
|
|
42
|
+
"noMatch": "Geen icoon past bij “{{query}}”"
|
|
43
|
+
},
|
|
44
|
+
"zoom": {
|
|
45
|
+
"out": "Uitzoomen",
|
|
46
|
+
"in": "Inzoomen",
|
|
47
|
+
"presets": "Zoomstanden",
|
|
48
|
+
"fit": "Zoomen tot alles past",
|
|
49
|
+
"fitEverything": "Alles laten passen"
|
|
50
|
+
},
|
|
51
|
+
"waveform": {
|
|
52
|
+
"position": "{{time}} van {{duration}}"
|
|
53
|
+
},
|
|
54
|
+
"projectSwitcher": {
|
|
55
|
+
"label": "Projecten",
|
|
56
|
+
"recent": "Recente projecten",
|
|
57
|
+
"actionsFor": "Acties voor {{name}}"
|
|
58
|
+
},
|
|
59
|
+
"tabs": {
|
|
60
|
+
"more": "Meer",
|
|
61
|
+
"close": "Tabblad sluiten"
|
|
27
62
|
}
|
|
28
|
-
},
|
|
29
|
-
"controls": {
|
|
30
|
-
"stepper": {
|
|
31
|
-
"smaller": "Kleiner",
|
|
32
|
-
"larger": "Groter",
|
|
33
|
-
"smallerFor": "{{label}}: kleiner",
|
|
34
|
-
"largerFor": "{{label}}: groter"
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
"iconPicker": {
|
|
38
|
-
"label": "Icoon",
|
|
39
|
-
"none": "Geen icoon",
|
|
40
|
-
"search": "Zoek een icoon",
|
|
41
|
-
"clear": "Zoekopdracht wissen",
|
|
42
|
-
"noMatch": "Geen icoon past bij “{{query}}”"
|
|
43
|
-
},
|
|
44
|
-
"zoom": {
|
|
45
|
-
"out": "Uitzoomen",
|
|
46
|
-
"in": "Inzoomen",
|
|
47
|
-
"presets": "Zoomstanden",
|
|
48
|
-
"fit": "Zoomen tot alles past",
|
|
49
|
-
"fitEverything": "Alles laten passen"
|
|
50
|
-
},
|
|
51
|
-
"waveform": {
|
|
52
|
-
"position": "{{time}} van {{duration}}"
|
|
53
|
-
},
|
|
54
|
-
"projectSwitcher": {
|
|
55
|
-
"label": "Projecten",
|
|
56
|
-
"recent": "Recente projecten",
|
|
57
|
-
"actionsFor": "Acties voor {{name}}"
|
|
58
|
-
}
|
|
59
63
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TabsCount as Count, TabsList as List, TabsPanel as Panel, TabsRoot as Root, TabsTab as Tab } from './parts.tsx';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The tabs a strip shows, by index, given the width of every tab, the room the strip has, the gap
|
|
3
|
+
* between two tabs and the width of the button that opens the rest. Everything fits, or the tabs fit
|
|
4
|
+
* from the start up to the button. The picked tab is never left to the menu: it takes the place of
|
|
5
|
+
* the last ones that no longer fit beside it.
|
|
6
|
+
*/
|
|
7
|
+
export const shownTabs = (widths: readonly number[], room: number, gap: number, more: number, picked: number): number[] => {
|
|
8
|
+
const span = (indices: readonly number[]): number => indices.reduce((sum, index) => sum + widths[index]!, 0) + gap * Math.max(0, indices.length - 1);
|
|
9
|
+
const all = widths.map((_, index) => index);
|
|
10
|
+
// Half a pixel of slack, since a measured width is fractional and the room is not.
|
|
11
|
+
if (span(all) <= room + 0.5) {
|
|
12
|
+
return all;
|
|
13
|
+
}
|
|
14
|
+
const limit = room - more - gap + 0.5;
|
|
15
|
+
const shown: number[] = [];
|
|
16
|
+
for (const index of all) {
|
|
17
|
+
if (span([...shown, index]) > limit) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
shown.push(index);
|
|
21
|
+
}
|
|
22
|
+
if (picked < 0 || picked >= widths.length || shown.includes(picked)) {
|
|
23
|
+
return shown;
|
|
24
|
+
}
|
|
25
|
+
while (shown.length > 0 && span([...shown, picked]) > limit) {
|
|
26
|
+
shown.pop();
|
|
27
|
+
}
|
|
28
|
+
return [...shown, picked];
|
|
29
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useContext,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentProps,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode
|
|
12
|
+
} from 'react';
|
|
13
|
+
import clsx from 'clsx';
|
|
14
|
+
import { ChevronDown, X } from 'lucide-react';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import { Tabs as BaseTabs } from '@base-ui-components/react/tabs';
|
|
17
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
18
|
+
import { withClass } from '../class-name.ts';
|
|
19
|
+
import { formatNumber } from '../format/number.ts';
|
|
20
|
+
import { Icon } from '../Icon.tsx';
|
|
21
|
+
import { IconButton } from '../IconButton.tsx';
|
|
22
|
+
import { MenuItem, MenuPopup, MenuRoot, MenuTrigger } from '../menu/parts.tsx';
|
|
23
|
+
import { Pill } from '../Pill.tsx';
|
|
24
|
+
import { shownTabs } from './overflow.ts';
|
|
25
|
+
|
|
26
|
+
export type TabsValue = BaseTabs.Tab.Value;
|
|
27
|
+
|
|
28
|
+
interface Selection {
|
|
29
|
+
value: TabsValue;
|
|
30
|
+
select(value: TabsValue): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SelectionContext = createContext<Selection>({ value: undefined, select: () => {} });
|
|
34
|
+
|
|
35
|
+
/* Whether a part is drawn in the menu of tabs that do not fit, where a count goes to the end of its row. */
|
|
36
|
+
const InMenuContext = createContext(false);
|
|
37
|
+
|
|
38
|
+
/* The underline sits over the strip's border rather than above it, without a negative margin that would make the strip scroll. */
|
|
39
|
+
const TAB =
|
|
40
|
+
'relative flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap text-xs font-medium text-text-muted hover:text-text data-[active]:text-text data-[disabled]:opacity-50 after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 data-[active]:after:bg-text';
|
|
41
|
+
|
|
42
|
+
/* The button that opens the tabs that do not fit reads as one more tab, at the very end of the strip. */
|
|
43
|
+
const MORE = clsx(TAB, 'ml-auto data-[popup-open]:text-text');
|
|
44
|
+
|
|
45
|
+
export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueChange'> & {
|
|
46
|
+
onValueChange?(value: TabsValue): void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/* Holds the picked tab itself, uncontrolled or not, so a tab picked from the menu of the strip changes it too. */
|
|
50
|
+
export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }: TabsRootProps) {
|
|
51
|
+
const [own, setOwn] = useState<TabsValue>(defaultValue);
|
|
52
|
+
const current = value === undefined ? own : value;
|
|
53
|
+
const select = (next: TabsValue): void => {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
setOwn(next);
|
|
56
|
+
}
|
|
57
|
+
onValueChange?.(next);
|
|
58
|
+
};
|
|
59
|
+
return (
|
|
60
|
+
<SelectionContext value={{ value: current, select }}>
|
|
61
|
+
<BaseTabs.Root value={current} onValueChange={(next) => select(next)} {...props} />
|
|
62
|
+
</SelectionContext>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'className'> & {
|
|
67
|
+
/* On the strip, which runs the width of the pane; padding here insets the tabs from its edges. */
|
|
68
|
+
className?: string;
|
|
69
|
+
/* Drawn after the tabs, such as a button that adds one. It keeps its own width: the tabs fit in what is left. */
|
|
70
|
+
end?: ReactNode;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const sameIndices = (left: readonly number[] | null, right: readonly number[]): boolean =>
|
|
74
|
+
left !== null && left.length === right.length && left.every((index, at) => index === right[at]);
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* The tabs that fit, and a menu at the end of the strip with the ones that do not. Every tab is measured
|
|
78
|
+
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
79
|
+
* since the menu repeats what a tab holds.
|
|
80
|
+
*/
|
|
81
|
+
export function TabsList({ className, children, end, ...props }: TabsListProps) {
|
|
82
|
+
const { t } = useTranslation('ui');
|
|
83
|
+
const { value, select } = useContext(SelectionContext);
|
|
84
|
+
const strip = useRef<HTMLDivElement>(null);
|
|
85
|
+
const measure = useRef<HTMLDivElement>(null);
|
|
86
|
+
const tail = useRef<HTMLDivElement>(null);
|
|
87
|
+
// Null until the first measure, which runs before the first paint.
|
|
88
|
+
const [shown, setShown] = useState<number[] | null>(null);
|
|
89
|
+
const tabs = Children.toArray(children).filter(
|
|
90
|
+
(child): child is ReactElement<ComponentProps<typeof TabsTab>> => isValidElement(child) && child.type === TabsTab
|
|
91
|
+
);
|
|
92
|
+
const picked = tabs.findIndex((tab) => tab.props.value === value);
|
|
93
|
+
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const host = strip.current;
|
|
96
|
+
const row = measure.current;
|
|
97
|
+
if (!host || !row) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const fit = (): void => {
|
|
101
|
+
const style = getComputedStyle(host);
|
|
102
|
+
const gap = parseFloat(style.columnGap) || 0;
|
|
103
|
+
const taken = tail.current === null ? 0 : tail.current.getBoundingClientRect().width + gap;
|
|
104
|
+
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - taken;
|
|
105
|
+
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
106
|
+
const more = cells.pop() ?? 0;
|
|
107
|
+
const next = shownTabs(cells, room, parseFloat(getComputedStyle(row).columnGap) || 0, more, picked);
|
|
108
|
+
setShown((current) => (sameIndices(current, next) ? current : next));
|
|
109
|
+
};
|
|
110
|
+
fit();
|
|
111
|
+
const observer = new ResizeObserver(fit);
|
|
112
|
+
observer.observe(host);
|
|
113
|
+
observer.observe(row);
|
|
114
|
+
if (tail.current !== null) {
|
|
115
|
+
observer.observe(tail.current);
|
|
116
|
+
}
|
|
117
|
+
return () => observer.disconnect();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const visible = shown === null ? tabs : shown.map((index) => tabs[index]!);
|
|
121
|
+
const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div ref={strip} className={clsx('relative flex items-center gap-4 border-b border-border', className)}>
|
|
125
|
+
<BaseTabs.List className="flex min-w-0 items-end gap-4" {...props}>
|
|
126
|
+
{visible}
|
|
127
|
+
</BaseTabs.List>
|
|
128
|
+
{hidden.length > 0 && (
|
|
129
|
+
<MenuRoot>
|
|
130
|
+
<MenuTrigger className={MORE}>
|
|
131
|
+
{t('tabs.more')}
|
|
132
|
+
<Icon icon={ChevronDown} size={14} />
|
|
133
|
+
</MenuTrigger>
|
|
134
|
+
<MenuPopup align="end" className="min-w-48">
|
|
135
|
+
<InMenuContext value>
|
|
136
|
+
{hidden.map((tab) => (
|
|
137
|
+
<MenuItem key={tab.key} disabled={tab.props.disabled} onClick={() => select(tab.props.value)}>
|
|
138
|
+
{tab.props.children}
|
|
139
|
+
</MenuItem>
|
|
140
|
+
))}
|
|
141
|
+
</InMenuContext>
|
|
142
|
+
</MenuPopup>
|
|
143
|
+
</MenuRoot>
|
|
144
|
+
)}
|
|
145
|
+
{end !== undefined && end !== null && (
|
|
146
|
+
<div ref={tail} className={clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto')}>
|
|
147
|
+
{end}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
<div ref={measure} aria-hidden inert className="pointer-events-none invisible absolute top-0 left-0 flex gap-4">
|
|
151
|
+
{tabs.map((tab) => (
|
|
152
|
+
<span key={tab.key} className={TAB}>
|
|
153
|
+
{tab.props.children}
|
|
154
|
+
{tab.props.onClose !== undefined && <span className="size-5 shrink-0" />}
|
|
155
|
+
</span>
|
|
156
|
+
))}
|
|
157
|
+
<span className={MORE}>
|
|
158
|
+
{t('tabs.more')}
|
|
159
|
+
<Icon icon={ChevronDown} size={14} />
|
|
160
|
+
</span>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type TabProps = ComponentProps<typeof BaseTabs.Tab>;
|
|
167
|
+
|
|
168
|
+
export type TabsTabProps = TabProps & {
|
|
169
|
+
/* Makes the tab closable: a close button in it, a middle click, and Delete or Backspace while the tab has focus. */
|
|
170
|
+
onClose?(): void;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/* The close button stays out of the tab order, so a closable tab is still one stop; the keys close it from the tab itself. */
|
|
174
|
+
export function TabsTab({ className, onClose, children, ...props }: TabsTabProps) {
|
|
175
|
+
const { t } = useTranslation('ui');
|
|
176
|
+
|
|
177
|
+
if (onClose === undefined) {
|
|
178
|
+
return (
|
|
179
|
+
<BaseTabs.Tab className={withClass(TAB, className)} {...props}>
|
|
180
|
+
{children}
|
|
181
|
+
</BaseTabs.Tab>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { onKeyDown, onAuxClick, onMouseDown, ...rest } = props;
|
|
186
|
+
|
|
187
|
+
const handleKeyDown = (e: Parameters<NonNullable<TabProps['onKeyDown']>>[0]): void => {
|
|
188
|
+
onKeyDown?.(e);
|
|
189
|
+
if (!e.defaultPrevented && e.target === e.currentTarget && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
onClose();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// A middle press starts autoscroll on some platforms; the release is what closes.
|
|
196
|
+
const handleMouseDown = (e: Parameters<NonNullable<TabProps['onAuxClick']>>[0]): void => {
|
|
197
|
+
onMouseDown?.(e);
|
|
198
|
+
if (e.button === 1) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleAuxClick = (e: Parameters<NonNullable<TabProps['onAuxClick']>>[0]): void => {
|
|
204
|
+
onAuxClick?.(e);
|
|
205
|
+
if (!e.defaultPrevented && e.button === 1) {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
onClose();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<BaseTabs.Tab
|
|
213
|
+
nativeButton={false}
|
|
214
|
+
render={<div />}
|
|
215
|
+
className={withClass(TAB, className)}
|
|
216
|
+
onKeyDown={handleKeyDown}
|
|
217
|
+
onMouseDown={handleMouseDown}
|
|
218
|
+
onAuxClick={handleAuxClick}
|
|
219
|
+
{...rest}
|
|
220
|
+
>
|
|
221
|
+
{children}
|
|
222
|
+
<IconButton
|
|
223
|
+
icon={X}
|
|
224
|
+
size="2xs"
|
|
225
|
+
label={t('tabs.close')}
|
|
226
|
+
tabIndex={-1}
|
|
227
|
+
onClick={(e) => {
|
|
228
|
+
e.stopPropagation();
|
|
229
|
+
onClose();
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
</BaseTabs.Tab>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const TabsPanel = BaseTabs.Panel;
|
|
237
|
+
|
|
238
|
+
export type TabsCountProps = Omit<useRender.ComponentProps<'span'>, 'children'> & {
|
|
239
|
+
value: number;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/* How much a tab holds, after its label. Nothing at zero: an empty view needs no number to say so. */
|
|
243
|
+
export function TabsCount({ value, render, className, ref, ...props }: TabsCountProps) {
|
|
244
|
+
const inMenu = useContext(InMenuContext);
|
|
245
|
+
const element = useRender({
|
|
246
|
+
render,
|
|
247
|
+
ref,
|
|
248
|
+
defaultTagName: 'span',
|
|
249
|
+
props: {
|
|
250
|
+
...props,
|
|
251
|
+
children: <Pill className="tabular-nums">{formatNumber(value)}</Pill>,
|
|
252
|
+
className: clsx('inline-flex shrink-0', inMenu && 'ml-auto', className)
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return value === 0 ? null : element;
|
|
256
|
+
}
|
package/src/theme.css
CHANGED
|
@@ -154,6 +154,9 @@
|
|
|
154
154
|
/* No default palette: a component reaches only the semantic colors below. */
|
|
155
155
|
--color-*: initial;
|
|
156
156
|
--spacing: round(0.25rem, 1px);
|
|
157
|
+
/* Only for a label that rides in a row of `xs`, such as a pill: 12px, the smallest the theme sets. */
|
|
158
|
+
--text-2xs: round(0.8rem, 1px);
|
|
159
|
+
--text-2xs--line-height: round(1.25rem, 1px);
|
|
157
160
|
--text-xs: round(0.875rem, 1px);
|
|
158
161
|
--text-xs--line-height: round(1.25rem, 1px);
|
|
159
162
|
--text-sm: round(0.9375rem, 1px);
|