@adecore/ui 0.14.0 → 0.15.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/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 +3 -0
- package/dist/locales/nl.json +3 -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 +18 -0
- package/dist/tabs/parts.js +89 -0
- package/dist/theme.css +3 -0
- package/package.json +1 -1
- package/src/Pill.tsx +14 -9
- 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 +3 -0
- package/src/locales/nl.json +3 -0
- package/src/tabs/index.parts.ts +1 -0
- package/src/tabs/overflow.ts +29 -0
- package/src/tabs/parts.tsx +163 -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/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, 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,18 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactElement } 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
|
+
};
|
|
12
|
+
export declare function TabsList({ className, children, ...props }: TabsListProps): import("react").JSX.Element;
|
|
13
|
+
export declare function TabsTab({ className, ...props }: ComponentProps<typeof BaseTabs.Tab>): import("react").JSX.Element;
|
|
14
|
+
export declare const TabsPanel: import("react").ForwardRefExoticComponent<import("@base-ui-components/react/tabs").TabsPanelProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
15
|
+
export type TabsCountProps = Omit<useRender.ComponentProps<'span'>, 'children'> & {
|
|
16
|
+
value: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function TabsCount({ value, render, className, ref, ...props }: TabsCountProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
|
|
@@ -0,0 +1,89 @@
|
|
|
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 } 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 { MenuItem, MenuPopup, MenuRoot, MenuTrigger } from '../menu/parts.js';
|
|
12
|
+
import { shownTabs } from './overflow.js';
|
|
13
|
+
const SelectionContext = createContext({ value: undefined, select: () => { } });
|
|
14
|
+
/* Whether a part is drawn in the menu of tabs that do not fit, where a count goes to the end of its row. */
|
|
15
|
+
const InMenuContext = createContext(false);
|
|
16
|
+
/* The underline sits over the strip's border rather than above it, without a negative margin that would make the strip scroll. */
|
|
17
|
+
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';
|
|
18
|
+
/* The button that opens the tabs that do not fit reads as one more tab, at the very end of the strip. */
|
|
19
|
+
const MORE = clsx(TAB, 'ml-auto data-[popup-open]:text-text');
|
|
20
|
+
/* Holds the picked tab itself, uncontrolled or not, so a tab picked from the menu of the strip changes it too. */
|
|
21
|
+
export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }) {
|
|
22
|
+
const [own, setOwn] = useState(defaultValue);
|
|
23
|
+
const current = value === undefined ? own : value;
|
|
24
|
+
const select = (next) => {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
setOwn(next);
|
|
27
|
+
}
|
|
28
|
+
onValueChange?.(next);
|
|
29
|
+
};
|
|
30
|
+
return (_jsx(SelectionContext, { value: { value: current, select }, children: _jsx(BaseTabs.Root, { value: current, onValueChange: (next) => select(next), ...props }) }));
|
|
31
|
+
}
|
|
32
|
+
const sameIndices = (left, right) => left !== null && left.length === right.length && left.every((index, at) => index === right[at]);
|
|
33
|
+
/*
|
|
34
|
+
* The tabs that fit, and a menu at the end of the strip with the ones that do not. Every tab is measured
|
|
35
|
+
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
36
|
+
* since the menu repeats what a tab holds.
|
|
37
|
+
*/
|
|
38
|
+
export function TabsList({ className, children, ...props }) {
|
|
39
|
+
const { t } = useTranslation('ui');
|
|
40
|
+
const { value, select } = useContext(SelectionContext);
|
|
41
|
+
const strip = useRef(null);
|
|
42
|
+
const measure = useRef(null);
|
|
43
|
+
// Null until the first measure, which runs before the first paint.
|
|
44
|
+
const [shown, setShown] = useState(null);
|
|
45
|
+
const tabs = Children.toArray(children).filter((child) => isValidElement(child) && child.type === TabsTab);
|
|
46
|
+
const picked = tabs.findIndex((tab) => tab.props.value === value);
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
const host = strip.current;
|
|
49
|
+
const row = measure.current;
|
|
50
|
+
if (!host || !row) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const fit = () => {
|
|
54
|
+
const style = getComputedStyle(host);
|
|
55
|
+
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
56
|
+
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
57
|
+
const more = cells.pop() ?? 0;
|
|
58
|
+
const next = shownTabs(cells, room, parseFloat(getComputedStyle(row).columnGap) || 0, more, picked);
|
|
59
|
+
setShown((current) => (sameIndices(current, next) ? current : next));
|
|
60
|
+
};
|
|
61
|
+
fit();
|
|
62
|
+
const observer = new ResizeObserver(fit);
|
|
63
|
+
observer.observe(host);
|
|
64
|
+
observer.observe(row);
|
|
65
|
+
return () => observer.disconnect();
|
|
66
|
+
});
|
|
67
|
+
const visible = shown === null ? tabs : shown.map((index) => tabs[index]);
|
|
68
|
+
const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
|
|
69
|
+
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))) }) })] })), _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) => (_jsx("span", { className: TAB, children: tab.props.children }, tab.key))), _jsxs("span", { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] })] })] }));
|
|
70
|
+
}
|
|
71
|
+
export function TabsTab({ className, ...props }) {
|
|
72
|
+
return _jsx(BaseTabs.Tab, { className: withClass(TAB, className), ...props });
|
|
73
|
+
}
|
|
74
|
+
export const TabsPanel = BaseTabs.Panel;
|
|
75
|
+
/* How much a tab holds, after its label. Nothing at zero: an empty view needs no number to say so. */
|
|
76
|
+
export function TabsCount({ value, render, className, ref, ...props }) {
|
|
77
|
+
const inMenu = useContext(InMenuContext);
|
|
78
|
+
const element = useRender({
|
|
79
|
+
render,
|
|
80
|
+
ref,
|
|
81
|
+
defaultTagName: 'span',
|
|
82
|
+
props: {
|
|
83
|
+
...props,
|
|
84
|
+
children: formatNumber(value),
|
|
85
|
+
className: clsx('rounded-full bg-surface-sunken px-1.5 text-xs text-text-muted tabular-nums', inMenu && 'ml-auto', className)
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return value === 0 ? null : element;
|
|
89
|
+
}
|
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/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, 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
package/src/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,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,163 @@
|
|
|
1
|
+
import { Children, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactElement } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Tabs as BaseTabs } from '@base-ui-components/react/tabs';
|
|
6
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
7
|
+
import { withClass } from '../class-name.ts';
|
|
8
|
+
import { formatNumber } from '../format/number.ts';
|
|
9
|
+
import { Icon } from '../Icon.tsx';
|
|
10
|
+
import { MenuItem, MenuPopup, MenuRoot, MenuTrigger } from '../menu/parts.tsx';
|
|
11
|
+
import { shownTabs } from './overflow.ts';
|
|
12
|
+
|
|
13
|
+
export type TabsValue = BaseTabs.Tab.Value;
|
|
14
|
+
|
|
15
|
+
interface Selection {
|
|
16
|
+
value: TabsValue;
|
|
17
|
+
select(value: TabsValue): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SelectionContext = createContext<Selection>({ value: undefined, select: () => {} });
|
|
21
|
+
|
|
22
|
+
/* Whether a part is drawn in the menu of tabs that do not fit, where a count goes to the end of its row. */
|
|
23
|
+
const InMenuContext = createContext(false);
|
|
24
|
+
|
|
25
|
+
/* The underline sits over the strip's border rather than above it, without a negative margin that would make the strip scroll. */
|
|
26
|
+
const TAB =
|
|
27
|
+
'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';
|
|
28
|
+
|
|
29
|
+
/* The button that opens the tabs that do not fit reads as one more tab, at the very end of the strip. */
|
|
30
|
+
const MORE = clsx(TAB, 'ml-auto data-[popup-open]:text-text');
|
|
31
|
+
|
|
32
|
+
export type TabsRootProps = Omit<ComponentProps<typeof BaseTabs.Root>, 'onValueChange'> & {
|
|
33
|
+
onValueChange?(value: TabsValue): void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/* Holds the picked tab itself, uncontrolled or not, so a tab picked from the menu of the strip changes it too. */
|
|
37
|
+
export function TabsRoot({ value, defaultValue = 0, onValueChange, ...props }: TabsRootProps) {
|
|
38
|
+
const [own, setOwn] = useState<TabsValue>(defaultValue);
|
|
39
|
+
const current = value === undefined ? own : value;
|
|
40
|
+
const select = (next: TabsValue): void => {
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
setOwn(next);
|
|
43
|
+
}
|
|
44
|
+
onValueChange?.(next);
|
|
45
|
+
};
|
|
46
|
+
return (
|
|
47
|
+
<SelectionContext value={{ value: current, select }}>
|
|
48
|
+
<BaseTabs.Root value={current} onValueChange={(next) => select(next)} {...props} />
|
|
49
|
+
</SelectionContext>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, 'className'> & {
|
|
54
|
+
/* On the strip, which runs the width of the pane; padding here insets the tabs from its edges. */
|
|
55
|
+
className?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const sameIndices = (left: readonly number[] | null, right: readonly number[]): boolean =>
|
|
59
|
+
left !== null && left.length === right.length && left.every((index, at) => index === right[at]);
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* The tabs that fit, and a menu at the end of the strip with the ones that do not. Every tab is measured
|
|
63
|
+
* in a row nobody sees, so the strip knows what fits before it draws. The tabs are the direct children,
|
|
64
|
+
* since the menu repeats what a tab holds.
|
|
65
|
+
*/
|
|
66
|
+
export function TabsList({ className, children, ...props }: TabsListProps) {
|
|
67
|
+
const { t } = useTranslation('ui');
|
|
68
|
+
const { value, select } = useContext(SelectionContext);
|
|
69
|
+
const strip = useRef<HTMLDivElement>(null);
|
|
70
|
+
const measure = useRef<HTMLDivElement>(null);
|
|
71
|
+
// Null until the first measure, which runs before the first paint.
|
|
72
|
+
const [shown, setShown] = useState<number[] | null>(null);
|
|
73
|
+
const tabs = Children.toArray(children).filter(
|
|
74
|
+
(child): child is ReactElement<ComponentProps<typeof TabsTab>> => isValidElement(child) && child.type === TabsTab
|
|
75
|
+
);
|
|
76
|
+
const picked = tabs.findIndex((tab) => tab.props.value === value);
|
|
77
|
+
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
const host = strip.current;
|
|
80
|
+
const row = measure.current;
|
|
81
|
+
if (!host || !row) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const fit = (): void => {
|
|
85
|
+
const style = getComputedStyle(host);
|
|
86
|
+
const room = host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
87
|
+
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
|
88
|
+
const more = cells.pop() ?? 0;
|
|
89
|
+
const next = shownTabs(cells, room, parseFloat(getComputedStyle(row).columnGap) || 0, more, picked);
|
|
90
|
+
setShown((current) => (sameIndices(current, next) ? current : next));
|
|
91
|
+
};
|
|
92
|
+
fit();
|
|
93
|
+
const observer = new ResizeObserver(fit);
|
|
94
|
+
observer.observe(host);
|
|
95
|
+
observer.observe(row);
|
|
96
|
+
return () => observer.disconnect();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const visible = shown === null ? tabs : shown.map((index) => tabs[index]!);
|
|
100
|
+
const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div ref={strip} className={clsx('relative flex items-center gap-4 border-b border-border', className)}>
|
|
104
|
+
<BaseTabs.List className="flex min-w-0 items-end gap-4" {...props}>
|
|
105
|
+
{visible}
|
|
106
|
+
</BaseTabs.List>
|
|
107
|
+
{hidden.length > 0 && (
|
|
108
|
+
<MenuRoot>
|
|
109
|
+
<MenuTrigger className={MORE}>
|
|
110
|
+
{t('tabs.more')}
|
|
111
|
+
<Icon icon={ChevronDown} size={14} />
|
|
112
|
+
</MenuTrigger>
|
|
113
|
+
<MenuPopup align="end" className="min-w-48">
|
|
114
|
+
<InMenuContext value>
|
|
115
|
+
{hidden.map((tab) => (
|
|
116
|
+
<MenuItem key={tab.key} disabled={tab.props.disabled} onClick={() => select(tab.props.value)}>
|
|
117
|
+
{tab.props.children}
|
|
118
|
+
</MenuItem>
|
|
119
|
+
))}
|
|
120
|
+
</InMenuContext>
|
|
121
|
+
</MenuPopup>
|
|
122
|
+
</MenuRoot>
|
|
123
|
+
)}
|
|
124
|
+
<div ref={measure} aria-hidden inert className="pointer-events-none invisible absolute top-0 left-0 flex gap-4">
|
|
125
|
+
{tabs.map((tab) => (
|
|
126
|
+
<span key={tab.key} className={TAB}>
|
|
127
|
+
{tab.props.children}
|
|
128
|
+
</span>
|
|
129
|
+
))}
|
|
130
|
+
<span className={MORE}>
|
|
131
|
+
{t('tabs.more')}
|
|
132
|
+
<Icon icon={ChevronDown} size={14} />
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function TabsTab({ className, ...props }: ComponentProps<typeof BaseTabs.Tab>) {
|
|
140
|
+
return <BaseTabs.Tab className={withClass(TAB, className)} {...props} />;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const TabsPanel = BaseTabs.Panel;
|
|
144
|
+
|
|
145
|
+
export type TabsCountProps = Omit<useRender.ComponentProps<'span'>, 'children'> & {
|
|
146
|
+
value: number;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/* How much a tab holds, after its label. Nothing at zero: an empty view needs no number to say so. */
|
|
150
|
+
export function TabsCount({ value, render, className, ref, ...props }: TabsCountProps) {
|
|
151
|
+
const inMenu = useContext(InMenuContext);
|
|
152
|
+
const element = useRender({
|
|
153
|
+
render,
|
|
154
|
+
ref,
|
|
155
|
+
defaultTagName: 'span',
|
|
156
|
+
props: {
|
|
157
|
+
...props,
|
|
158
|
+
children: formatNumber(value),
|
|
159
|
+
className: clsx('rounded-full bg-surface-sunken px-1.5 text-xs text-text-muted tabular-nums', inMenu && 'ml-auto', className)
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return value === 0 ? null : element;
|
|
163
|
+
}
|
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);
|