@tangle-network/ui 11.34.0 → 11.36.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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/lib/control-presentation.d.ts +34 -1
- package/dist/lib/control-presentation.js +1 -1
- package/dist/lib/focus.d.ts +2 -2
- package/dist/lib/focus.js +3 -2
- package/dist/lib/motion.d.ts +4 -0
- package/dist/lib/motion.js +7 -0
- package/dist/lib/surface-motion.js +84 -0
- package/dist/presentation.d.ts +88 -0
- package/dist/presentation.js +183 -0
- package/dist/primitives/alert-dialog.js +1 -1
- package/dist/primitives/alert.d.ts +1 -2
- package/dist/primitives/button.d.ts +2 -5
- package/dist/primitives/button.js +3 -30
- package/dist/primitives/card.js +3 -7
- package/dist/primitives/date-picker.d.ts +1 -1
- package/dist/primitives/dialog.js +29 -16
- package/dist/primitives/empty-state.js +6 -5
- package/dist/primitives/heading.d.ts +1 -0
- package/dist/primitives/heading.js +3 -2
- package/dist/primitives/help-text.js +2 -1
- package/dist/primitives/input.d.ts +1 -4
- package/dist/primitives/input.js +3 -24
- package/dist/primitives/label.js +1 -2
- package/dist/primitives/metric-strip.js +7 -12
- package/dist/primitives/page-header.d.ts +1 -0
- package/dist/primitives/page-header.js +7 -6
- package/dist/primitives/page-shell.d.ts +8 -2
- package/dist/primitives/page-shell.js +21 -2
- package/dist/primitives/pagination.js +1 -1
- package/dist/primitives/segmented-control.d.ts +6 -3
- package/dist/primitives/segmented-control.js +105 -56
- package/dist/primitives/sheet.js +29 -19
- package/dist/primitives/skeleton.js +1 -1
- package/dist/primitives/status-pill.d.ts +3 -1
- package/dist/primitives/status-pill.js +12 -3
- package/dist/primitives/table.js +10 -9
- package/dist/primitives/tabs.js +3 -12
- package/dist/primitives/tooltip.js +11 -3
- package/dist/primitives.d.ts +3 -2
- package/dist/primitives.js +2 -1
- package/dist/utils.d.ts +1 -1
- package/package.json +15 -5
- package/src/lib/control-presentation.ts +2 -4
- package/src/lib/focus.ts +3 -2
- package/src/lib/motion.ts +7 -0
- package/src/lib/surface-motion.tsx +78 -0
- package/src/presentation.ts +205 -0
- package/src/primitives/alert.tsx +1 -2
- package/src/primitives/button.tsx +4 -40
- package/src/primitives/card.tsx +5 -10
- package/src/primitives/checkbox-separator-sheet-tooltip.test.tsx +2 -1
- package/src/primitives/control-presentation.test.tsx +2 -7
- package/src/primitives/dialog.tsx +19 -18
- package/src/primitives/empty-state.tsx +6 -5
- package/src/primitives/heading.tsx +4 -2
- package/src/primitives/help-text.tsx +3 -2
- package/src/primitives/input.tsx +3 -31
- package/src/primitives/label.tsx +2 -4
- package/src/primitives/metric-strip.tsx +8 -17
- package/src/primitives/page-header.tsx +8 -5
- package/src/primitives/page-shell.tsx +13 -2
- package/src/primitives/segmented-control.stories.tsx +89 -0
- package/src/primitives/segmented-control.test.tsx +20 -7
- package/src/primitives/segmented-control.tsx +90 -29
- package/src/primitives/sheet.tsx +16 -11
- package/src/primitives/skeleton.tsx +1 -1
- package/src/primitives/status-pill.test.tsx +1 -0
- package/src/primitives/status-pill.tsx +10 -4
- package/src/primitives/table.tsx +10 -9
- package/src/primitives/tabs.tsx +5 -17
- package/src/primitives/tooltip.tsx +6 -5
- package/src/styles/compat.css +12 -0
- package/src/styles/presentation.css +30 -0
- package/src/primitives/page-shell.test.tsx +0 -24
|
@@ -38,6 +38,7 @@ export interface HeadingProps
|
|
|
38
38
|
extends Omit<React.HTMLAttributes<HTMLElement>, "role"> {
|
|
39
39
|
/** Visual treatment, independent of the rendered element. Defaults to section. */
|
|
40
40
|
variant?: HeadingVariant;
|
|
41
|
+
presentation?: "operational" | "expressive";
|
|
41
42
|
/** Semantic element; changing it does not change the visual variant. */
|
|
42
43
|
as?: React.ElementType;
|
|
43
44
|
/**
|
|
@@ -53,7 +54,7 @@ export interface HeadingProps
|
|
|
53
54
|
* display/page h1 per page; choose `as` according to the document hierarchy.
|
|
54
55
|
*/
|
|
55
56
|
const Heading = React.forwardRef<HTMLElement, HeadingProps>(
|
|
56
|
-
({ variant, role, as, className, ...props }, ref) => {
|
|
57
|
+
({ variant, role, as, className, presentation, ...props }, ref) => {
|
|
57
58
|
const legacyVariant =
|
|
58
59
|
role && Object.prototype.hasOwnProperty.call(VARIANT_CLASS, role)
|
|
59
60
|
? (role as HeadingVariant)
|
|
@@ -64,7 +65,8 @@ const Heading = React.forwardRef<HTMLElement, HeadingProps>(
|
|
|
64
65
|
return (
|
|
65
66
|
<Tag
|
|
66
67
|
ref={ref}
|
|
67
|
-
className={cn("[overflow-wrap:anywhere]", VARIANT_CLASS[visualVariant], className)}
|
|
68
|
+
className={cn("[overflow-wrap:anywhere]", presentation ? (presentation === "expressive" ? "tangle-display" : "tangle-heading") : VARIANT_CLASS[visualVariant], className)}
|
|
69
|
+
data-variant={presentation ? visualVariant : undefined}
|
|
68
70
|
role={legacyVariant ? undefined : role}
|
|
69
71
|
{...props}
|
|
70
72
|
/>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { helpPresentation } from "../presentation";
|
|
1
2
|
import * as React from "react";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
|
|
@@ -17,8 +18,8 @@ const HelpText = React.forwardRef<HTMLParagraphElement, HelpTextProps>(
|
|
|
17
18
|
ref={ref}
|
|
18
19
|
data-tone={tone}
|
|
19
20
|
className={cn(
|
|
20
|
-
|
|
21
|
-
tone
|
|
21
|
+
helpPresentation.root,
|
|
22
|
+
helpPresentation[tone],
|
|
22
23
|
className,
|
|
23
24
|
)}
|
|
24
25
|
{...props}
|
package/src/primitives/input.tsx
CHANGED
|
@@ -1,45 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { controlRadius, controlSizes, controlText, fieldPresentation,
|
|
4
|
+
import { controlRadius, controlSizes, controlText, fieldPresentation, fieldTouchText } from "../lib/control-presentation";
|
|
5
5
|
import { focusFieldInvalid } from "../lib/focus";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
import { HelpText } from "./help-text";
|
|
8
8
|
import { Label } from "./label";
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { inputVariants } from "../presentation";
|
|
11
|
+
import { type VariantProps } from "class-variance-authority";
|
|
11
12
|
|
|
12
13
|
// Sizes follow the shared control scale (control-presentation.ts), so an Input
|
|
13
14
|
// and a Button at the same size share a height, text size and corner.
|
|
14
|
-
const inputVariants = cva(
|
|
15
|
-
cn(
|
|
16
|
-
"flex w-full border py-1 placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium",
|
|
17
|
-
fieldPresentation,
|
|
18
|
-
fieldTouchText,
|
|
19
|
-
),
|
|
20
|
-
{
|
|
21
|
-
variants: {
|
|
22
|
-
// `default` and `sandbox` render the same field; both names stay valid.
|
|
23
|
-
variant: {
|
|
24
|
-
default: "",
|
|
25
|
-
sandbox: "",
|
|
26
|
-
error: focusFieldInvalid,
|
|
27
|
-
},
|
|
28
|
-
size: {
|
|
29
|
-
default: fieldSizes.md,
|
|
30
|
-
md: fieldSizes.md,
|
|
31
|
-
sm: fieldSizes.sm,
|
|
32
|
-
lg: fieldSizes.lg,
|
|
33
|
-
compact: cn(fieldSizes.md, controlSizes.compact),
|
|
34
|
-
touch: cn(fieldSizes.md, controlSizes.touch),
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
defaultVariants: {
|
|
38
|
-
variant: "default",
|
|
39
|
-
size: "default",
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
);
|
|
43
15
|
|
|
44
16
|
// Only rendered descriptions are added; caller IDs and their order are retained.
|
|
45
17
|
function descriptionIds(...ids: Array<string | undefined>) {
|
package/src/primitives/label.tsx
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
4
|
-
import {
|
|
4
|
+
import { labelVariants } from "../presentation";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
|
|
8
8
|
// One label size (`--font-size-label`, 14px) for every field size.
|
|
9
|
-
|
|
10
|
-
"font-medium text-[length:var(--font-size-label,0.875rem)] leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
11
|
-
);
|
|
9
|
+
|
|
12
10
|
|
|
13
11
|
const Label = React.forwardRef<
|
|
14
12
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
@@ -1,17 +1,8 @@
|
|
|
1
|
+
import { metricPresentation, metricLayouts } from "../presentation";
|
|
1
2
|
import * as React from "react";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
import { StatusPill, type StatusTone } from "./status-pill";
|
|
4
5
|
|
|
5
|
-
// Each finite preset owns its grid AND its row-start rules. Keep the strings
|
|
6
|
-
// literal so host Tailwind consumers scanning the published source emit them.
|
|
7
|
-
// Ranges do not overlap: no drawn border is subsequently suppressed.
|
|
8
|
-
const layouts = {
|
|
9
|
-
3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
|
|
10
|
-
4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
|
|
11
|
-
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
|
|
12
|
-
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l",
|
|
13
|
-
} as const;
|
|
14
|
-
|
|
15
6
|
/** Headline readings on one plane, with native definition-list semantics. */
|
|
16
7
|
export interface MetricStripProps
|
|
17
8
|
extends React.HTMLAttributes<HTMLDListElement> {
|
|
@@ -26,8 +17,8 @@ const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
|
|
|
26
17
|
<dl
|
|
27
18
|
ref={ref}
|
|
28
19
|
className={cn(
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
metricPresentation.strip,
|
|
21
|
+
metricLayouts[columns],
|
|
31
22
|
className,
|
|
32
23
|
)}
|
|
33
24
|
{...props}
|
|
@@ -55,10 +46,10 @@ const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
|
55
46
|
({ className, label, value, hint, attention, icon, ...props }, ref) => (
|
|
56
47
|
<div
|
|
57
48
|
ref={ref}
|
|
58
|
-
className={cn(
|
|
49
|
+
className={cn(metricPresentation.item, className)}
|
|
59
50
|
{...props}
|
|
60
51
|
>
|
|
61
|
-
<dt className=
|
|
52
|
+
<dt className={metricPresentation.label}>
|
|
62
53
|
{icon != null && icon !== false && (
|
|
63
54
|
<span
|
|
64
55
|
aria-hidden="true"
|
|
@@ -67,7 +58,7 @@ const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
|
67
58
|
{icon}
|
|
68
59
|
</span>
|
|
69
60
|
)}
|
|
70
|
-
<span className=
|
|
61
|
+
<span className={metricPresentation.labelText}>{label}</span>
|
|
71
62
|
{attention && (
|
|
72
63
|
<StatusPill tone={attention.tone}>{attention.label}</StatusPill>
|
|
73
64
|
)}
|
|
@@ -76,7 +67,7 @@ const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
|
76
67
|
title. Wrap even unbroken values without making the grid wider. */}
|
|
77
68
|
<dd
|
|
78
69
|
className={cn(
|
|
79
|
-
|
|
70
|
+
metricPresentation.value,
|
|
80
71
|
attention?.tone === "danger" && "text-[var(--surface-danger-text)]",
|
|
81
72
|
)}
|
|
82
73
|
title={typeof value === "string" ? value : undefined}
|
|
@@ -85,7 +76,7 @@ const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
|
85
76
|
</dd>
|
|
86
77
|
{hint !== undefined && hint !== null && hint !== false && hint !== "" && (
|
|
87
78
|
<dd
|
|
88
|
-
className=
|
|
79
|
+
className={metricPresentation.hint}
|
|
89
80
|
title={typeof hint === "string" ? hint : undefined}
|
|
90
81
|
>
|
|
91
82
|
{hint}
|
|
@@ -10,6 +10,7 @@ import { Heading } from "./heading";
|
|
|
10
10
|
export interface PageHeaderProps
|
|
11
11
|
extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
12
12
|
title: React.ReactNode;
|
|
13
|
+
presentation?: "operational" | "expressive";
|
|
13
14
|
description?: React.ReactNode;
|
|
14
15
|
/** Canonical action slot. If supplied (even null), takes precedence over action. */
|
|
15
16
|
actions?: React.ReactNode;
|
|
@@ -31,6 +32,7 @@ const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
|
|
|
31
32
|
(
|
|
32
33
|
{
|
|
33
34
|
className,
|
|
35
|
+
presentation,
|
|
34
36
|
title,
|
|
35
37
|
description,
|
|
36
38
|
actions,
|
|
@@ -46,28 +48,29 @@ const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
|
|
|
46
48
|
) => {
|
|
47
49
|
const resolvedActions = actions === undefined ? action : actions;
|
|
48
50
|
return (
|
|
49
|
-
<header ref={ref} className={cn("mb-6 min-w-0", className)} {...props}>
|
|
50
|
-
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-6 gap-y-3">
|
|
51
|
+
<header ref={ref} className={cn(presentation === "expressive" ? "tangle-intro" : presentation === "operational" ? "tangle-page-header" : "mb-6 min-w-0", className)} {...props}>
|
|
52
|
+
<div className={presentation ? "tangle-page-header-main" : "flex min-w-0 flex-wrap items-start justify-between gap-x-6 gap-y-3"}>
|
|
51
53
|
<div className="min-w-0 flex-[1_1_16rem]">
|
|
52
54
|
{hasContent(eyebrow) && (
|
|
53
55
|
<Heading variant="eyebrow" className="mb-1.5">{eyebrow}</Heading>
|
|
54
56
|
)}
|
|
55
57
|
<Heading
|
|
56
58
|
id={titleId}
|
|
57
|
-
variant={level === 1 ? "page" : "section"}
|
|
59
|
+
variant={presentation === "expressive" ? "display" : level === 1 ? "page" : "section"}
|
|
60
|
+
presentation={presentation}
|
|
58
61
|
as={titleAs ?? (level === 1 ? "h1" : "h2")}
|
|
59
62
|
className="text-balance"
|
|
60
63
|
>
|
|
61
64
|
{title}
|
|
62
65
|
</Heading>
|
|
63
66
|
{hasContent(description) && (
|
|
64
|
-
<p className="mt-1 max-w-prose text-muted-foreground text-sm [overflow-wrap:anywhere]">
|
|
67
|
+
<p className={presentation === "expressive" ? "tangle-intro-description" : presentation ? "tangle-page-description" : "mt-1 max-w-prose text-muted-foreground text-sm [overflow-wrap:anywhere]"}>
|
|
65
68
|
{description}
|
|
66
69
|
</p>
|
|
67
70
|
)}
|
|
68
71
|
</div>
|
|
69
72
|
{hasContent(resolvedActions) && (
|
|
70
|
-
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2 [overflow-wrap:anywhere] [&>*]:min-w-0 [&>*]:max-w-full">
|
|
73
|
+
<div className={presentation === "expressive" ? "tangle-intro-actions" : "flex min-w-0 max-w-full flex-wrap items-center gap-2 [overflow-wrap:anywhere] [&>*]:min-w-0 [&>*]:max-w-full"}>
|
|
71
74
|
{resolvedActions}
|
|
72
75
|
</div>
|
|
73
76
|
)}
|
|
@@ -2,7 +2,13 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
3
|
|
|
4
4
|
export interface PageShellProps {
|
|
5
|
-
children
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
/** Optional reading layout. Content, routing and navigation interaction stay caller-owned. */
|
|
7
|
+
navigation?: ReactNode;
|
|
8
|
+
article?: ReactNode;
|
|
9
|
+
outline?: ReactNode;
|
|
10
|
+
navigationLabel?: string;
|
|
11
|
+
outlineLabel?: string;
|
|
6
12
|
className?: string;
|
|
7
13
|
}
|
|
8
14
|
|
|
@@ -12,7 +18,12 @@ export interface PageShellProps {
|
|
|
12
18
|
* The caller owns landmarks, routing, auth, navigation and data state. Use
|
|
13
19
|
* className to override the bound/gutters, or omit it for a workbench layout.
|
|
14
20
|
*/
|
|
15
|
-
export function PageShell({ children, className }: PageShellProps) {
|
|
21
|
+
export function PageShell({ children, className, navigation, article, outline, navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
|
|
22
|
+
if (navigation || article || outline) return <div className={cn("tangle-reading-shell",className)}>
|
|
23
|
+
{navigation && <nav aria-label={navigationLabel} className="tangle-reading-nav">{navigation}</nav>}
|
|
24
|
+
<article className="tangle-reading-article">{article ?? children}</article>
|
|
25
|
+
{outline && <aside aria-label={outlineLabel} className="tangle-reading-outline">{outline}</aside>}
|
|
26
|
+
</div>;
|
|
16
27
|
return (
|
|
17
28
|
<div
|
|
18
29
|
className={cn(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
|
|
4
|
+
|
|
5
|
+
const meta: Meta = {
|
|
6
|
+
title: "Primitives/SegmentedControl",
|
|
7
|
+
parameters: { layout: "fullscreen" },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default meta
|
|
11
|
+
type Story = StoryObj
|
|
12
|
+
|
|
13
|
+
const workTabs: SegmentedControlOption[] = [
|
|
14
|
+
{ value: "today", label: "Today" },
|
|
15
|
+
{ value: "messages", label: "Messages" },
|
|
16
|
+
{ value: "tasks", label: "Tasks" },
|
|
17
|
+
{ value: "operations", label: "Operations" },
|
|
18
|
+
{ value: "payments", label: "Payments" },
|
|
19
|
+
{ value: "approvals", label: "Approvals" },
|
|
20
|
+
{ value: "activity", label: "Activity" },
|
|
21
|
+
{ value: "people", label: "People" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const fileSources: SegmentedControlOption[] = [
|
|
25
|
+
{ value: "knowledge", label: "Knowledge" },
|
|
26
|
+
{ value: "attachments", label: "Attachments" },
|
|
27
|
+
{ value: "apps", label: "Apps & chats" },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
function Controlled({
|
|
31
|
+
initial,
|
|
32
|
+
render,
|
|
33
|
+
}: {
|
|
34
|
+
initial: string
|
|
35
|
+
render: (value: string, setValue: (value: string) => void) => React.ReactNode
|
|
36
|
+
}) {
|
|
37
|
+
const [value, setValue] = React.useState(initial)
|
|
38
|
+
return <>{render(value, setValue)}</>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Eight section tabs on a 390 px phone, as a workspace header renders them:
|
|
43
|
+
* full width with the page's 16 px inline padding on the strip itself. Most
|
|
44
|
+
* tabs sit past the right edge, the selected one starts off screen, and the
|
|
45
|
+
* cut lands in the gap between two tabs, so only a chevron shows that the strip
|
|
46
|
+
* scrolls.
|
|
47
|
+
*/
|
|
48
|
+
export const OverflowingTabs: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<div className="w-full max-w-[390px] py-4">
|
|
51
|
+
<Controlled
|
|
52
|
+
initial="approvals"
|
|
53
|
+
render={(value, setValue) => (
|
|
54
|
+
<SegmentedControl
|
|
55
|
+
data-testid="overflow-tabs"
|
|
56
|
+
aria-label="Work"
|
|
57
|
+
variant="tabs"
|
|
58
|
+
value={value}
|
|
59
|
+
onValueChange={setValue}
|
|
60
|
+
options={workTabs}
|
|
61
|
+
className="w-full px-4"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A row control a host made scroll instead of wrap, beside a page title. */
|
|
70
|
+
export const OverflowingRow: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<div className="flex w-full max-w-[390px] items-center gap-3 px-4 py-4">
|
|
73
|
+
<h2 className="shrink-0 text-lg font-semibold">Files</h2>
|
|
74
|
+
<Controlled
|
|
75
|
+
initial="knowledge"
|
|
76
|
+
render={(value, setValue) => (
|
|
77
|
+
<SegmentedControl
|
|
78
|
+
data-testid="overflow-row"
|
|
79
|
+
aria-label="File source"
|
|
80
|
+
value={value}
|
|
81
|
+
onValueChange={setValue}
|
|
82
|
+
options={fileSources}
|
|
83
|
+
className="min-w-0 flex-nowrap overflow-x-auto"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
}
|
|
@@ -361,13 +361,14 @@ describe("SegmentedControl tabs on a narrow strip", () => {
|
|
|
361
361
|
Object.defineProperty(proto, key, { configurable: true, ...descriptor })
|
|
362
362
|
}
|
|
363
363
|
const isStrip = (el: Element) => el.getAttribute("role") === "radiogroup"
|
|
364
|
-
|
|
364
|
+
// Options sit side by side; the edge chevrons take no room in the row.
|
|
365
|
+
const index = (el: Element) => Array.from(el.parentElement?.querySelectorAll(':scope > [role="radio"]') ?? []).indexOf(el)
|
|
365
366
|
|
|
366
367
|
beforeEach(() => {
|
|
367
368
|
define(HTMLElement.prototype, "offsetLeft", { get(this: HTMLElement) { return isStrip(this) ? 0 : index(this) * TAB } })
|
|
368
369
|
define(HTMLElement.prototype, "offsetWidth", { get(this: HTMLElement) { return isStrip(this) ? STRIP : TAB } })
|
|
369
370
|
define(Element.prototype, "clientWidth", { get(this: Element) { return isStrip(this) ? STRIP : TAB } })
|
|
370
|
-
define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.
|
|
371
|
+
define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.querySelectorAll(':scope > [role="radio"]').length * TAB : TAB } })
|
|
371
372
|
define(Element.prototype, "scrollLeft", {
|
|
372
373
|
get(this: Element) { return scroll.get(this) ?? 0 },
|
|
373
374
|
set(this: Element, v: number) { scroll.set(this, v) },
|
|
@@ -390,32 +391,44 @@ describe("SegmentedControl tabs on a narrow strip", () => {
|
|
|
390
391
|
<SegmentedControl aria-label="Work" variant="tabs" value="payments" onValueChange={vi.fn()} options={work} />,
|
|
391
392
|
)
|
|
392
393
|
const strip = screen.getByRole("radiogroup")
|
|
393
|
-
// Payments spans 400-500px: its right edge
|
|
394
|
-
expect(strip.scrollLeft).toBe(500 - STRIP +
|
|
394
|
+
// Payments spans 400-500px: its right edge clears the 32px chevron, plus a 4px gap, in the 250px view.
|
|
395
|
+
expect(strip.scrollLeft).toBe(500 - STRIP + 32 + 4)
|
|
395
396
|
|
|
396
397
|
rerender(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
|
|
397
398
|
expect(strip.scrollLeft).toBe(0)
|
|
398
399
|
})
|
|
399
400
|
|
|
400
|
-
it("
|
|
401
|
+
it("shows a chevron at the edge that has more tabs past it", () => {
|
|
401
402
|
const { rerender } = render(
|
|
402
403
|
<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />,
|
|
403
404
|
)
|
|
404
405
|
const strip = screen.getByRole("radiogroup")
|
|
405
406
|
expect(strip).toHaveAttribute("data-overflow-end", "true")
|
|
406
407
|
expect(strip).not.toHaveAttribute("data-overflow-start")
|
|
407
|
-
expect(strip.
|
|
408
|
+
expect(strip.querySelector('[data-scroll-cue="end"]')).not.toBeNull()
|
|
409
|
+
expect(strip.querySelector('[data-scroll-cue="start"]')).toBeNull()
|
|
408
410
|
|
|
409
411
|
rerender(<SegmentedControl aria-label="Work" variant="tabs" value="people" onValueChange={vi.fn()} options={work} />)
|
|
410
412
|
expect(strip).toHaveAttribute("data-overflow-start", "true")
|
|
411
413
|
expect(strip).not.toHaveAttribute("data-overflow-end")
|
|
414
|
+
expect(strip.querySelector('[data-scroll-cue="start"]')).not.toBeNull()
|
|
415
|
+
expect(strip.querySelector('[data-scroll-cue="end"]')).toBeNull()
|
|
412
416
|
})
|
|
413
417
|
|
|
414
|
-
it("
|
|
418
|
+
it("keeps the chevrons out of the radio group and the tab order", () => {
|
|
419
|
+
render(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
|
|
420
|
+
expect(screen.getAllByRole("radio")).toHaveLength(work.length)
|
|
421
|
+
const cue = screen.getByRole("radiogroup").querySelector('[data-scroll-cue="end"]')
|
|
422
|
+
expect(cue).toHaveAttribute("aria-hidden", "true")
|
|
423
|
+
expect(cue).toHaveAttribute("tabindex", "-1")
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it("leaves a strip that fits without chevrons", () => {
|
|
415
427
|
render(<SegmentedControl aria-label="Work" variant="tabs" value="all" onValueChange={vi.fn()} options={options.slice(0, 2)} />)
|
|
416
428
|
const strip = screen.getByRole("radiogroup")
|
|
417
429
|
expect(strip).not.toHaveAttribute("data-overflow-start")
|
|
418
430
|
expect(strip).not.toHaveAttribute("data-overflow-end")
|
|
431
|
+
expect(strip.querySelector("[data-scroll-cue]")).toBeNull()
|
|
419
432
|
})
|
|
420
433
|
})
|
|
421
434
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
declare const process: { env: { NODE_ENV?: string } } | undefined
|
|
4
4
|
|
|
5
|
+
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
5
6
|
import * as React from "react"
|
|
6
7
|
import { touchTarget } from "../lib/control-presentation"
|
|
7
8
|
import { focusRing, focusRingInset } from "../lib/focus"
|
|
@@ -32,9 +33,12 @@ import { cn } from "../lib/utils"
|
|
|
32
33
|
* - Every label keeps one weight. Selection is colour plus surface or
|
|
33
34
|
* underline, so changing the value never reflows the row.
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* past it
|
|
36
|
+
* When the options outgrow the row they scroll sideways (the tabs variant
|
|
37
|
+
* always can; the row variant can when a host gives it `flex-nowrap
|
|
38
|
+
* overflow-x-auto`). A chevron button pinned to each edge with options past it
|
|
39
|
+
* shows that the strip scrolls and pages it on click or tap; touch and
|
|
40
|
+
* trackpad scroll it natively, and arrow keys move the selection, which is
|
|
41
|
+
* kept fully in view clear of the chevrons.
|
|
38
42
|
*/
|
|
39
43
|
export interface SegmentedControlOption<T extends string = string> {
|
|
40
44
|
value: T
|
|
@@ -61,14 +65,47 @@ export interface SegmentedControlProps<T extends string = string>
|
|
|
61
65
|
"data-testid"?: string
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
/** Size of the edge chevron, in px. The selected option is kept this far
|
|
69
|
+
* (plus the strip's own padding) from an edge so the chevron never covers it. */
|
|
70
|
+
const SCROLL_CUE_SIZE = 32
|
|
71
|
+
/** The strip's flex gap (`gap-1`). A cue's negative margin cancels its width
|
|
72
|
+
* and this gap, so showing it never shifts the options: a shift is not a
|
|
73
|
+
* resize, so nothing would re-reveal the selected option afterwards. */
|
|
74
|
+
const STRIP_GAP = 4
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A chevron pinned to one edge of the scrolling strip. It is a sticky flex
|
|
78
|
+
* item with a negative margin equal to its width plus the gap, so it takes no room in the
|
|
79
|
+
* row (showing it never reflows the options) and stays at the visible edge
|
|
80
|
+
* while the strip scrolls. A solid surface keeps it legible on any plane,
|
|
81
|
+
* which an edge fade is not: a fade over the gap between two options, or over
|
|
82
|
+
* a host's inline padding, shows nothing.
|
|
83
|
+
*/
|
|
84
|
+
function ScrollCue({ side, onPage }: { side: "start" | "end"; onPage: () => void }) {
|
|
85
|
+
const Icon = side === "start" ? ChevronLeft : ChevronRight
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
// Pointer and touch affordance only: keyboard users move the selection
|
|
90
|
+
// with the arrow keys, and the strip keeps it in view.
|
|
91
|
+
tabIndex={-1}
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
data-scroll-cue={side}
|
|
94
|
+
onClick={onPage}
|
|
95
|
+
className={cn(
|
|
96
|
+
"sticky z-[1] flex shrink-0 items-center justify-center self-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground",
|
|
97
|
+
side === "start" ? "left-0" : "right-0",
|
|
98
|
+
)}
|
|
99
|
+
style={{
|
|
100
|
+
width: SCROLL_CUE_SIZE,
|
|
101
|
+
height: SCROLL_CUE_SIZE,
|
|
102
|
+
[side === "start" ? "marginRight" : "marginLeft"]: -(SCROLL_CUE_SIZE + STRIP_GAP),
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
106
|
+
</button>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
72
109
|
|
|
73
110
|
export function SegmentedControl<T extends string = string>({
|
|
74
111
|
value,
|
|
@@ -92,7 +129,9 @@ export function SegmentedControl<T extends string = string>({
|
|
|
92
129
|
const optionRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map())
|
|
93
130
|
const stripRef = React.useRef<HTMLDivElement>(null)
|
|
94
131
|
const [overflow, setOverflow] = React.useState({ start: false, end: false })
|
|
95
|
-
|
|
132
|
+
// Re-observe when the option set changes, not on every render of a host
|
|
133
|
+
// that builds its options array inline.
|
|
134
|
+
const optionKey = options.map((o) => o.value).join("\u0000")
|
|
96
135
|
|
|
97
136
|
const measureOverflow = React.useCallback(() => {
|
|
98
137
|
const strip = stripRef.current
|
|
@@ -102,32 +141,52 @@ export function SegmentedControl<T extends string = string>({
|
|
|
102
141
|
setOverflow((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
|
|
103
142
|
}, [])
|
|
104
143
|
|
|
105
|
-
// Keep the selected
|
|
106
|
-
// never moves, which scrollIntoView cannot
|
|
107
|
-
|
|
108
|
-
|
|
144
|
+
// Keep the selected option inside the strip and clear of the chevrons.
|
|
145
|
+
// Only the strip scrolls: the page never moves, which scrollIntoView cannot
|
|
146
|
+
// promise. A strip that does not overflow ignores the assignment.
|
|
147
|
+
const valueRef = React.useRef(value)
|
|
148
|
+
valueRef.current = value
|
|
149
|
+
const revealSelected = React.useCallback(() => {
|
|
109
150
|
const strip = stripRef.current
|
|
110
|
-
const option = optionRefs.current.get(
|
|
111
|
-
if (strip && option) {
|
|
151
|
+
const option = optionRefs.current.get(valueRef.current)
|
|
152
|
+
if (strip && option && strip.scrollWidth > strip.clientWidth) {
|
|
153
|
+
const style = getComputedStyle(strip)
|
|
154
|
+
// offsetLeft/offsetWidth round to whole pixels while the tab is fractional (a 101.42 px tab overlapped the
|
|
155
|
+
// chevron by 0.66 px at 390 px), so keep a small gap beside each cue rather than touching its edge.
|
|
156
|
+
const gap = 4
|
|
157
|
+
const reserveStart = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingLeft) || 0)
|
|
158
|
+
const reserveEnd = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingRight) || 0)
|
|
112
159
|
const start = option.offsetLeft
|
|
113
160
|
const end = start + option.offsetWidth
|
|
114
|
-
if (start < strip.scrollLeft
|
|
115
|
-
strip.scrollLeft = Math.max(0, start -
|
|
116
|
-
} else if (end > strip.scrollLeft + strip.clientWidth
|
|
117
|
-
strip.scrollLeft = end - strip.clientWidth
|
|
161
|
+
if (start - reserveStart < strip.scrollLeft) {
|
|
162
|
+
strip.scrollLeft = Math.max(0, start - reserveStart)
|
|
163
|
+
} else if (end + reserveEnd > strip.scrollLeft + strip.clientWidth) {
|
|
164
|
+
strip.scrollLeft = end + reserveEnd - strip.clientWidth
|
|
118
165
|
}
|
|
119
166
|
}
|
|
120
167
|
measureOverflow()
|
|
121
|
-
}, [
|
|
168
|
+
}, [measureOverflow])
|
|
122
169
|
|
|
170
|
+
React.useLayoutEffect(revealSelected, [value, options.length, revealSelected])
|
|
171
|
+
|
|
172
|
+
// The strip's own size and its options' sizes (late web fonts, a changed
|
|
173
|
+
// label or adornment) move both the selected option and the overflow edges.
|
|
123
174
|
React.useEffect(() => {
|
|
124
|
-
if (
|
|
175
|
+
if (typeof ResizeObserver === "undefined") return
|
|
125
176
|
const strip = stripRef.current
|
|
126
177
|
if (!strip) return
|
|
127
|
-
const observer = new ResizeObserver(
|
|
178
|
+
const observer = new ResizeObserver(revealSelected)
|
|
128
179
|
observer.observe(strip)
|
|
180
|
+
for (const option of optionRefs.current.values()) observer.observe(option)
|
|
129
181
|
return () => observer.disconnect()
|
|
130
|
-
}, [
|
|
182
|
+
}, [optionKey, revealSelected])
|
|
183
|
+
|
|
184
|
+
const page = (direction: 1 | -1) => {
|
|
185
|
+
const strip = stripRef.current
|
|
186
|
+
if (!strip) return
|
|
187
|
+
const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
188
|
+
strip.scrollBy({ left: direction * strip.clientWidth * 0.75, behavior: reduce ? "auto" : "smooth" })
|
|
189
|
+
}
|
|
131
190
|
|
|
132
191
|
const hasMatch = options.some((o) => o.value === value)
|
|
133
192
|
|
|
@@ -161,16 +220,17 @@ export function SegmentedControl<T extends string = string>({
|
|
|
161
220
|
data-overflow-end={overflow.end || undefined}
|
|
162
221
|
ref={stripRef}
|
|
163
222
|
onKeyDown={handleKeyDown}
|
|
164
|
-
onScroll={
|
|
223
|
+
onScroll={measureOverflow}
|
|
165
224
|
className={cn(
|
|
166
|
-
"flex gap-1",
|
|
225
|
+
"relative flex gap-1",
|
|
167
226
|
variant === "row" &&
|
|
168
227
|
"flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1",
|
|
169
228
|
variant === "tabs" &&
|
|
170
|
-
|
|
229
|
+
"flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
|
|
171
230
|
className,
|
|
172
231
|
)}
|
|
173
232
|
>
|
|
233
|
+
{overflow.start && <ScrollCue side="start" onPage={() => page(-1)} />}
|
|
174
234
|
{options.map((option, i) => {
|
|
175
235
|
const active = option.value === value
|
|
176
236
|
const focusable = active || (!hasMatch && i === 0)
|
|
@@ -214,6 +274,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
214
274
|
</button>
|
|
215
275
|
)
|
|
216
276
|
})}
|
|
277
|
+
{overflow.end && <ScrollCue side="end" onPage={() => page(1)} />}
|
|
217
278
|
</div>
|
|
218
279
|
)
|
|
219
280
|
}
|