@tangle-network/ui 11.32.2 → 11.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/lib/control-presentation.js +2 -1
- package/dist/lib/focus.d.ts +5 -1
- package/dist/lib/focus.js +6 -1
- package/dist/lib/interaction.d.ts +41 -0
- package/dist/lib/interaction.js +42 -0
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/button.js +19 -18
- package/dist/primitives/card.d.ts +14 -1
- package/dist/primitives/card.js +15 -5
- package/dist/primitives/dropdown-menu.js +7 -6
- package/dist/primitives/segmented-control.d.ts +5 -1
- package/dist/primitives/segmented-control.js +12 -5
- package/dist/primitives/select.js +4 -3
- package/dist/primitives/tabs.js +4 -3
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +3 -2
- package/dist/utils.js +3 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +38 -3
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/lib/control-presentation.ts +10 -4
- package/src/lib/focus.ts +7 -1
- package/src/lib/interaction.ts +64 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/button.tsx +29 -10
- package/src/primitives/card.tsx +23 -6
- package/src/primitives/control-presentation.test.tsx +6 -3
- package/src/primitives/date-picker.test.tsx +1 -1
- package/src/primitives/dropdown-menu.tsx +15 -10
- package/src/primitives/interaction-states.test.tsx +285 -0
- package/src/primitives/segmented-control.test.tsx +4 -2
- package/src/primitives/segmented-control.tsx +13 -14
- package/src/primitives/select.tsx +11 -5
- package/src/primitives/tabs.tsx +7 -3
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +10 -1
|
@@ -20,6 +20,8 @@ export interface FileArtifactPaneEditorOptions {
|
|
|
20
20
|
enabled?: boolean;
|
|
21
21
|
/** Persisted baseline when content is the current draft. */
|
|
22
22
|
savedContent?: string;
|
|
23
|
+
/** Whether the draft has unsaved edits, when the host tracks that itself. Overrides `savedContent`. */
|
|
24
|
+
dirty?: boolean;
|
|
23
25
|
mode?: DocumentEditorMode;
|
|
24
26
|
defaultMode?: DocumentEditorMode;
|
|
25
27
|
onModeChange?: (mode: DocumentEditorMode) => void;
|
|
@@ -158,6 +160,7 @@ export function FileArtifactPane({
|
|
|
158
160
|
hideTitleBlock={hideTitleBlock}
|
|
159
161
|
markdown={content ?? ""}
|
|
160
162
|
savedMarkdown={editor.savedContent}
|
|
163
|
+
dirty={editor.dirty}
|
|
161
164
|
urlTransform={urlTransform}
|
|
162
165
|
mode={editor.mode}
|
|
163
166
|
defaultMode={editor.defaultMode}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { focusField } from "./focus";
|
|
2
|
+
import { ixField } from "./interaction";
|
|
2
3
|
import { cn } from "./utils";
|
|
3
4
|
|
|
4
5
|
// Time-valued tokens only: --transition-fast is a legacy duration + easing
|
|
@@ -66,16 +67,21 @@ export const controlSizes = {
|
|
|
66
67
|
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base",
|
|
67
68
|
} as const;
|
|
68
69
|
|
|
69
|
-
// --input is the shadcn border/track role, NOT a field background.
|
|
70
|
-
//
|
|
70
|
+
// --input is the shadcn border/track role, NOT a field background. The fill is
|
|
71
|
+
// `ixField`'s `--well`, the brand's surface-context variable: the page well at
|
|
72
|
+
// the top level, re-declared by each card level so a field is never the colour
|
|
73
|
+
// of the plane it sits on. `ixField` also replaces focusField's resting
|
|
74
|
+
// `border-border` and neutral hover with `--field-border` and the green hover
|
|
75
|
+
// edge; focusField's `:focus` border and halo are kept exactly.
|
|
71
76
|
// An autofill inset shadow coexists with the Tailwind focus ring; never hide
|
|
72
77
|
// autofill with a seconds-long background transition or clear the focus halo.
|
|
73
78
|
// --field-surface lets a context raise its fields: Toolbar sets it to the card
|
|
74
79
|
// surface so filters on the page canvas read as controls, not as holes in it.
|
|
75
80
|
export const fieldPresentation = cn(
|
|
76
81
|
focusField,
|
|
82
|
+
ixField,
|
|
77
83
|
controlMotion,
|
|
78
|
-
"min-w-0
|
|
84
|
+
"min-w-0 text-foreground",
|
|
79
85
|
"aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]",
|
|
80
|
-
"autofill:shadow-[inset_0_0_0_1000px_var(--
|
|
86
|
+
"autofill:shadow-[inset_0_0_0_1000px_var(--field-surface,var(--well))] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]",
|
|
81
87
|
);
|
package/src/lib/focus.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ixField } from "./interaction";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The focus treatment every control shares, in two shapes over one palette
|
|
3
5
|
* (`--focus-border`, `--focus-halo` and `--border-strong` from
|
|
@@ -42,9 +44,13 @@ export const focusField =
|
|
|
42
44
|
* compose your own classes instead of this helper. The field selector is
|
|
43
45
|
* written out in full because Tailwind reads class names statically.
|
|
44
46
|
* Base styles use its marker class to clear the inner editor fallback outline.
|
|
47
|
+
*
|
|
48
|
+
* The container is the field, so it takes `ixField` (lib/interaction.ts): its
|
|
49
|
+
* own well fill, the `--field-border` hairline and the green hover edge. The
|
|
50
|
+
* borderless field inside stays transparent.
|
|
45
51
|
*/
|
|
46
52
|
export const focusFieldWithin =
|
|
47
|
-
|
|
53
|
+
`focus-field-within transition-[border-color,box-shadow] duration-150 ease-out ${ixField} has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:border-[var(--focus-border)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-3 has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-[var(--focus-halo)]`;
|
|
48
54
|
|
|
49
55
|
/** Error state for a field: a danger hairline and the same focus shape in danger. Compose after `focusField`. */
|
|
50
56
|
export const focusFieldInvalid =
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction states every control shares: one palette in five shapes, all
|
|
3
|
+
* read from `@tangle-network/brand`'s `--ix-*` tokens (tokens.css), which derive
|
|
4
|
+
* from the focus ring hue `--hsl-ring`. So hover, press, after-click and
|
|
5
|
+
* selected are the same green in Hospitality and the same indigo on the spine
|
|
6
|
+
* as the keyboard focus ring.
|
|
7
|
+
*
|
|
8
|
+
* hover 1px edge at .45 plus a 4px halo at .16 (`--ix-hover`)
|
|
9
|
+
* press 1px edge at .95 plus a 4px halo at .30 (`--ix-press`)
|
|
10
|
+
* after a control focused by a mouse click keeps a soft ring (`--ix-after`)
|
|
11
|
+
* selected a tint plus a 1px inset ring at .85 (`--ix-sel-bg`, `--ix-sel-ring`)
|
|
12
|
+
*
|
|
13
|
+
* The keyboard focus-visible glow stays exactly as `focusRing` / `focusField`
|
|
14
|
+
* (lib/focus.ts) draw it. Tailwind composes `--tw-ring-shadow` with
|
|
15
|
+
* `--tw-shadow`, so these shadows coexist with that ring, and
|
|
16
|
+
* `focus-visible:shadow-none` drops the hover glow while the keyboard ring shows.
|
|
17
|
+
*
|
|
18
|
+
* Every class is written out in full because Tailwind reads class names
|
|
19
|
+
* statically.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Bordered or filled control: green edge on hover, deeper on press, a persistent soft ring after a mouse click. */
|
|
23
|
+
export const ixControl =
|
|
24
|
+
"hover:border-[var(--ix-hover-border)] hover:shadow-[var(--ix-hover)] " +
|
|
25
|
+
"focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)] " +
|
|
26
|
+
"active:border-[hsl(var(--hsl-ring))] active:shadow-[var(--ix-press)] focus-visible:shadow-none";
|
|
27
|
+
|
|
28
|
+
/** The after-click part of `ixControl` alone, for a field that opens a menu (SelectTrigger). */
|
|
29
|
+
export const ixControlAfter =
|
|
30
|
+
"focus:not-focus-visible:border-[var(--ix-hover-border)] focus:not-focus-visible:shadow-[var(--ix-after)]";
|
|
31
|
+
|
|
32
|
+
/** Ghost, icon, link and bare controls: a wash, never a glow (too noisy in sidebars and toolbars). */
|
|
33
|
+
export const ixControlSoft = "hover:bg-[var(--ix-hover-bg)] active:bg-[var(--ix-sel-bg)]";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Field: its own well and a visible border; hover replaces `focusField`'s
|
|
37
|
+
* neutral `--border-strong`. The well is the `--well` context variable, which a
|
|
38
|
+
* card re-declares for its descendants, so a field never takes its parent's
|
|
39
|
+
* colour. `--field-surface` still lets a context such as Toolbar pick the fill.
|
|
40
|
+
*/
|
|
41
|
+
export const ixField =
|
|
42
|
+
"bg-[var(--field-surface,var(--well))] border-[var(--field-border)] shadow-[var(--shadow-well)] " +
|
|
43
|
+
"hover:border-[var(--ix-hover-border)] hover:shadow-[var(--shadow-well),var(--ix-hover)]";
|
|
44
|
+
|
|
45
|
+
/** Selected / on / current: tint plus a 1px inset green ring. */
|
|
46
|
+
export const ixSelected =
|
|
47
|
+
"aria-pressed:bg-[var(--ix-sel-bg)] aria-pressed:text-[var(--accent-text)] aria-pressed:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] " +
|
|
48
|
+
"aria-checked:bg-[var(--ix-sel-bg)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] " +
|
|
49
|
+
"aria-[current=page]:bg-[var(--ix-sel-bg)] aria-[current=page]:text-[var(--accent-text)] aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] " +
|
|
50
|
+
"data-[state=on]:bg-[var(--ix-sel-bg)] data-[state=on]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] " +
|
|
51
|
+
"data-[state=active]:bg-[var(--ix-sel-bg)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]";
|
|
52
|
+
|
|
53
|
+
/** Underline tab: 2px green underline plus a faint tint, never a box; a faint underline on hover. */
|
|
54
|
+
export const ixTabUnderline =
|
|
55
|
+
"hover:shadow-[inset_0_-2px_0_hsl(var(--hsl-ring)/0.4)] " +
|
|
56
|
+
"data-[state=active]:bg-[hsl(var(--hsl-ring)/0.07)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_-2px_0_var(--ix-sel-ring)] " +
|
|
57
|
+
"aria-checked:bg-[hsl(var(--hsl-ring)/0.07)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_-2px_0_var(--ix-sel-ring)]";
|
|
58
|
+
|
|
59
|
+
/** List row / menu item: wash plus inset edge on hover or keyboard highlight; checked = selected. */
|
|
60
|
+
export const ixRow =
|
|
61
|
+
"hover:bg-[var(--ix-hover-bg)] hover:shadow-[inset_0_0_0_1px_var(--ix-hover-border)] " +
|
|
62
|
+
"data-[highlighted]:bg-[var(--ix-sel-bg)] data-[highlighted]:shadow-[inset_0_0_0_1px_var(--ix-hover-border)] " +
|
|
63
|
+
"focus:bg-[var(--ix-sel-bg)] data-[state=checked]:bg-[var(--ix-sel-bg)] data-[state=checked]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] " +
|
|
64
|
+
"active:shadow-[inset_0_0_0_1px_hsl(var(--hsl-ring))]";
|
|
@@ -45,6 +45,15 @@ describe("Markdown tables", () => {
|
|
|
45
45
|
expect(document.body.innerHTML).not.toContain("text-xs");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("contains its screen-reader status line, so a table deep in a scrolling panel cannot stretch the page", () => {
|
|
49
|
+
render(<Markdown>{TABLE}</Markdown>);
|
|
50
|
+
const status = screen.getByRole("status");
|
|
51
|
+
expect(status.className).toContain("sr-only");
|
|
52
|
+
// The status is absolutely positioned; the card it sits in must be its containing block.
|
|
53
|
+
const card = status.closest(".not-prose") as HTMLElement;
|
|
54
|
+
expect(card.className.split(" ")).toEqual(expect.arrayContaining(["relative", "overflow-hidden"]));
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
it("copies the table as CSV with quoting and formula-safe cells", async () => {
|
|
49
58
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
50
59
|
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
@@ -138,7 +138,11 @@ export function MarkdownTable({ node, className, children, ...props }: Component
|
|
|
138
138
|
return { csv: tableCsv([...header, ...body]), kinds: columnKinds(body), rowCount: body.length };
|
|
139
139
|
}, [node]);
|
|
140
140
|
return (
|
|
141
|
-
|
|
141
|
+
// `relative` makes the card the containing block of the copy control's
|
|
142
|
+
// screen-reader status line. Without it that absolutely positioned line
|
|
143
|
+
// resolves against the nearest positioned ancestor, often the page, and a
|
|
144
|
+
// table far down a scrolling panel stretches the page past the viewport.
|
|
145
|
+
<div className="not-prose relative my-4 overflow-hidden rounded-lg border border-border bg-card">
|
|
142
146
|
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1">
|
|
143
147
|
<span className="text-sm text-muted-foreground">{rowCount === 1 ? "1 row" : `${rowCount} rows`}</span>
|
|
144
148
|
<CopyCsvButton csv={csv} />
|
|
@@ -3,29 +3,35 @@ import { cva } from "class-variance-authority";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText } from "../lib/control-presentation";
|
|
5
5
|
import { focusRing } from "../lib/focus";
|
|
6
|
+
import { ixControl, ixControlSoft, ixSelected } from "../lib/interaction";
|
|
6
7
|
import { cn } from "../lib/utils";
|
|
7
8
|
|
|
8
9
|
// Sizes follow the shared control scale (control-presentation.ts): sm, md and
|
|
9
10
|
// lg match Input, Textarea and SelectTrigger at the same size. xl is a display
|
|
10
11
|
// call to action outside that scale.
|
|
12
|
+
//
|
|
13
|
+
// Interaction (lib/interaction.ts): a variant with a border or a fill takes
|
|
14
|
+
// `ixControl` (green edge and glow on hover, press and after a click); ghost,
|
|
15
|
+
// link and bare take `ixControlSoft`, a wash that never glows. A toggle button
|
|
16
|
+
// (any `aria-pressed`) adds `ixControl` + `ixSelected` whatever its variant.
|
|
11
17
|
const buttonVariants = cva(
|
|
12
18
|
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
|
|
13
19
|
{
|
|
14
20
|
variants: {
|
|
15
21
|
variant: {
|
|
16
22
|
default:
|
|
17
|
-
|
|
23
|
+
`bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] ${ixControl}`,
|
|
18
24
|
destructive:
|
|
19
|
-
|
|
25
|
+
`bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] ${ixControl}`,
|
|
20
26
|
outline:
|
|
21
|
-
|
|
27
|
+
`border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground ${ixControl}`,
|
|
22
28
|
secondary:
|
|
23
|
-
|
|
29
|
+
`bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] ${ixControl}`,
|
|
24
30
|
ghost:
|
|
25
|
-
|
|
26
|
-
link:
|
|
31
|
+
`hover:text-foreground text-muted-foreground border border-transparent ${ixControlSoft}`,
|
|
32
|
+
link: `text-[var(--accent-text)] underline-offset-4 hover:underline ${ixControlSoft}`,
|
|
27
33
|
sandbox:
|
|
28
|
-
|
|
34
|
+
`bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97] ${ixControl}`,
|
|
29
35
|
},
|
|
30
36
|
size: {
|
|
31
37
|
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
@@ -50,7 +56,20 @@ const buttonVariants = cva(
|
|
|
50
56
|
// every surface, size and type class. It is for pressable rows, tiles and pills
|
|
51
57
|
// that draw their own layout; anything that reads as a button uses a styled
|
|
52
58
|
// variant and a size.
|
|
53
|
-
const bareButton = `${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50`;
|
|
59
|
+
const bareButton = `${focusRing} ${ixControlSoft} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50`;
|
|
60
|
+
|
|
61
|
+
/** One Button render's classes: variant and size, the toggle states, then the caller's. */
|
|
62
|
+
function buttonClassName(
|
|
63
|
+
variant: ButtonProps["variant"],
|
|
64
|
+
size: ButtonProps["size"],
|
|
65
|
+
pressed: React.AriaAttributes["aria-pressed"],
|
|
66
|
+
className: string | undefined,
|
|
67
|
+
) {
|
|
68
|
+
const toggle = pressed === undefined ? undefined : `${ixControl} ${ixSelected}`;
|
|
69
|
+
return variant === "bare"
|
|
70
|
+
? cn(bareButton, toggle, className)
|
|
71
|
+
: cn(buttonVariants({ variant, size }), toggle, className);
|
|
72
|
+
}
|
|
54
73
|
|
|
55
74
|
export interface ButtonProps
|
|
56
75
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
@@ -115,7 +134,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
115
134
|
// Keep slotted content intact: its action name must survive loading too.
|
|
116
135
|
return (
|
|
117
136
|
<Slot
|
|
118
|
-
className={variant
|
|
137
|
+
className={buttonClassName(variant, size, props["aria-pressed"], className)}
|
|
119
138
|
ref={ref}
|
|
120
139
|
{...props}
|
|
121
140
|
{...(isDisabled ? disabledHandlers(props) : {})}
|
|
@@ -140,7 +159,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
140
159
|
|
|
141
160
|
return (
|
|
142
161
|
<button
|
|
143
|
-
className={variant
|
|
162
|
+
className={buttonClassName(variant, size, props["aria-pressed"], className)}
|
|
144
163
|
ref={ref}
|
|
145
164
|
{...props}
|
|
146
165
|
disabled={disabled || loading}
|
package/src/primitives/card.tsx
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { ixControl } from "../lib/interaction";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
import { Heading } from "./heading";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A card is one level of the brand surface ladder. It carries
|
|
8
|
+
* `data-slot="card"`, and the brand's surface-context rule (tokens.css)
|
|
9
|
+
* re-declares `--card` and `--well` for everything inside it: a Card or a
|
|
10
|
+
* `bg-card` box inside a card paints the raised level, one inside that the
|
|
11
|
+
* raised-2 level, and a field in any of them takes the well that is the
|
|
12
|
+
* opposite step from its parent. Nesting needs no prop and no app selector.
|
|
13
|
+
*/
|
|
5
14
|
const Card = React.forwardRef<
|
|
6
15
|
HTMLDivElement,
|
|
7
16
|
React.HTMLAttributes<HTMLDivElement> & {
|
|
8
17
|
variant?: "default" | "glass" | "sandbox" | "elevated";
|
|
9
|
-
/** Decorative
|
|
18
|
+
/** Decorative hover feedback only (the shared green edge). Put actions in a native button or link. */
|
|
10
19
|
hover?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The card is itself the control, such as a card wrapped in a link: it takes
|
|
22
|
+
* the full control states (hover glow, press, the ring after a click).
|
|
23
|
+
*/
|
|
24
|
+
interactive?: boolean;
|
|
11
25
|
}
|
|
12
|
-
>(({ className, variant = "default", hover = false, ...props }, ref) => {
|
|
26
|
+
>(({ className, variant = "default", hover = false, interactive = false, ...props }, ref) => {
|
|
13
27
|
const variants = {
|
|
14
|
-
default: "bg-card border-border",
|
|
28
|
+
default: "bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)]",
|
|
15
29
|
elevated: "bg-muted/50 border-border shadow-[var(--shadow-card)]",
|
|
16
30
|
glass: "bg-card/80 backdrop-blur-xl border-border shadow-[var(--shadow-card)]",
|
|
17
31
|
sandbox: "bg-muted/50 border-primary/20 shadow-[var(--shadow-accent)]",
|
|
@@ -21,12 +35,13 @@ const Card = React.forwardRef<
|
|
|
21
35
|
<div
|
|
22
36
|
ref={ref}
|
|
23
37
|
className={cn(
|
|
24
|
-
"rounded-[var(--radius-lg)] border text-card-foreground transition-[border-color,box-shadow]",
|
|
38
|
+
"rounded-[var(--radius-lg)] border text-card-foreground transition-[border-color,box-shadow,background-color]",
|
|
25
39
|
"duration-[var(--transition-default)]",
|
|
26
40
|
variants[variant],
|
|
27
|
-
hover &&
|
|
41
|
+
(hover || interactive) && ixControl,
|
|
28
42
|
className,
|
|
29
43
|
)}
|
|
44
|
+
data-slot="card"
|
|
30
45
|
{...props}
|
|
31
46
|
/>
|
|
32
47
|
);
|
|
@@ -69,7 +84,9 @@ const CardDescription = React.forwardRef<
|
|
|
69
84
|
>(({ className, ...props }, ref) => (
|
|
70
85
|
<p
|
|
71
86
|
ref={ref}
|
|
72
|
-
|
|
87
|
+
// Two lines at most: a guard, not a copy budget. Long copy belongs in help
|
|
88
|
+
// text or a disclosure.
|
|
89
|
+
className={cn("text-muted-foreground text-sm line-clamp-2", className)}
|
|
73
90
|
{...props}
|
|
74
91
|
/>
|
|
75
92
|
));
|
|
@@ -12,10 +12,13 @@ afterEach(cleanup);
|
|
|
12
12
|
|
|
13
13
|
describe("shared control presentation", () => {
|
|
14
14
|
it("uses the field well, not the border/track alias, with scoped foreground and time-only motion", () => {
|
|
15
|
-
|
|
15
|
+
// The well is the brand's --well context variable; Toolbar can still pick the fill.
|
|
16
|
+
expect(fieldPresentation).toContain("bg-[var(--field-surface,var(--well))]");
|
|
16
17
|
expect(fieldPresentation.split(" ")).not.toContain("bg-input");
|
|
17
18
|
expect(fieldPresentation.split(" ")).not.toContain("bg-card");
|
|
18
|
-
|
|
19
|
+
// ixField's --field-border replaces focusField's resting border-border.
|
|
20
|
+
expect(fieldPresentation.split(" ")).toContain("border-[var(--field-border)]");
|
|
21
|
+
expect(fieldPresentation.split(" ")).not.toContain("border-border");
|
|
19
22
|
expect(fieldPresentation).toContain("text-foreground");
|
|
20
23
|
expect(fieldPresentation).not.toContain("duration-150");
|
|
21
24
|
expect(controlMotion).toContain("duration-[var(--duration-fast)]");
|
|
@@ -100,7 +103,7 @@ describe("shared control presentation", () => {
|
|
|
100
103
|
expect(input.current).toHaveAttribute("aria-invalid", "true");
|
|
101
104
|
expect(input.current!.className).toContain("autofill:shadow-");
|
|
102
105
|
expect(input.current!.className.split(" ")).toContain("bg-card");
|
|
103
|
-
expect(input.current!.className).not.toContain("bg-[var(--field-surface,var(--
|
|
106
|
+
expect(input.current!.className).not.toContain("bg-[var(--field-surface,var(--well))]");
|
|
104
107
|
expect(textarea.current).toBeDisabled();
|
|
105
108
|
expect(trigger.current).toBe(screen.getByLabelText("Choice"));
|
|
106
109
|
expect(trigger.current).toBeDisabled();
|
|
@@ -128,7 +128,7 @@ describe("raised fields in a Toolbar", () => {
|
|
|
128
128
|
);
|
|
129
129
|
expect(screen.getByTestId("toolbar").className).toContain("[--field-surface:hsl(var(--card))]");
|
|
130
130
|
for (const name of ["Format", "Elsewhere"]) {
|
|
131
|
-
expect(screen.getByRole("combobox", { name }).className).toContain("bg-[var(--field-surface,var(--
|
|
131
|
+
expect(screen.getByRole("combobox", { name }).className).toContain("bg-[var(--field-surface,var(--well))]");
|
|
132
132
|
}
|
|
133
133
|
});
|
|
134
134
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
2
2
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { ixRow } from "../lib/interaction";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
|
|
6
7
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
@@ -24,8 +25,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|
|
24
25
|
<DropdownMenuPrimitive.SubTrigger
|
|
25
26
|
ref={ref}
|
|
26
27
|
className={cn(
|
|
27
|
-
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none",
|
|
28
|
-
"
|
|
28
|
+
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-[color,background-color,box-shadow]",
|
|
29
|
+
"data-[state=open]:bg-[var(--ix-sel-bg)]",
|
|
30
|
+
ixRow,
|
|
29
31
|
inset && "pl-8",
|
|
30
32
|
className,
|
|
31
33
|
)}
|
|
@@ -45,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|
|
45
47
|
<DropdownMenuPrimitive.SubContent
|
|
46
48
|
ref={ref}
|
|
47
49
|
className={cn(
|
|
48
|
-
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-
|
|
50
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-[var(--edge-highlight),var(--shadow-card)]",
|
|
49
51
|
"data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
50
52
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
51
53
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
@@ -68,7 +70,7 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
68
70
|
ref={ref}
|
|
69
71
|
sideOffset={sideOffset}
|
|
70
72
|
className={cn(
|
|
71
|
-
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-
|
|
73
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-[var(--edge-highlight),var(--shadow-card)]",
|
|
72
74
|
"data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
73
75
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
74
76
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
@@ -91,8 +93,9 @@ const DropdownMenuItem = React.forwardRef<
|
|
|
91
93
|
<DropdownMenuPrimitive.Item
|
|
92
94
|
ref={ref}
|
|
93
95
|
className={cn(
|
|
94
|
-
"relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-
|
|
95
|
-
"focus:
|
|
96
|
+
"relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-[color,background-color,box-shadow]",
|
|
97
|
+
"focus:text-foreground",
|
|
98
|
+
ixRow,
|
|
96
99
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
97
100
|
inset && "pl-8",
|
|
98
101
|
className,
|
|
@@ -109,8 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
|
109
112
|
<DropdownMenuPrimitive.CheckboxItem
|
|
110
113
|
ref={ref}
|
|
111
114
|
className={cn(
|
|
112
|
-
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm outline-none transition-
|
|
113
|
-
"focus:
|
|
115
|
+
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm outline-none transition-[color,background-color,box-shadow]",
|
|
116
|
+
"focus:text-foreground",
|
|
117
|
+
ixRow,
|
|
114
118
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
115
119
|
className,
|
|
116
120
|
)}
|
|
@@ -135,8 +139,9 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
135
139
|
<DropdownMenuPrimitive.RadioItem
|
|
136
140
|
ref={ref}
|
|
137
141
|
className={cn(
|
|
138
|
-
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm outline-none transition-
|
|
139
|
-
"focus:
|
|
142
|
+
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm outline-none transition-[color,background-color,box-shadow]",
|
|
143
|
+
"focus:text-foreground",
|
|
144
|
+
ixRow,
|
|
140
145
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
141
146
|
className,
|
|
142
147
|
)}
|