@kinetixui/ui 0.23.3 → 0.24.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/README.md +134 -0
- package/dist/index.d.ts +212 -31
- package/dist/index.js +1466 -1118
- package/dist/kx-src-hash.json +3 -0
- package/package.json +7 -7
- package/src/components/accordion.tsx +1 -1
- package/src/components/alert-dialog.tsx +7 -5
- package/src/components/avatar.tsx +4 -1
- package/src/components/banner.tsx +9 -3
- package/src/components/breadcrumb.tsx +1 -1
- package/src/components/button.tsx +1 -1
- package/src/components/card.tsx +77 -6
- package/src/components/checkbox.tsx +42 -8
- package/src/components/collapsible.tsx +41 -1
- package/src/components/command.tsx +2 -2
- package/src/components/context-menu.tsx +21 -13
- package/src/components/dialog.tsx +5 -4
- package/src/components/direction-provider.tsx +85 -1
- package/src/components/drawer.tsx +62 -18
- package/src/components/dropdown-menu.tsx +12 -4
- package/src/components/file-upload.tsx +1 -1
- package/src/components/hover-card.tsx +1 -1
- package/src/components/inform.tsx +9 -3
- package/src/components/input-group.tsx +27 -5
- package/src/components/input-otp.tsx +48 -5
- package/src/components/input.tsx +20 -3
- package/src/components/json-viewer.tsx +1 -1
- package/src/components/menubar.tsx +21 -15
- package/src/components/modal.tsx +3 -2
- package/src/components/multi-select.tsx +14 -5
- package/src/components/native-select.tsx +10 -2
- package/src/components/navigation-bar.tsx +1 -1
- package/src/components/navigation-menu.tsx +6 -6
- package/src/components/number-input.tsx +28 -10
- package/src/components/pagination.tsx +2 -2
- package/src/components/popover.tsx +29 -5
- package/src/components/radio-group.tsx +23 -8
- package/src/components/segmented-control.tsx +20 -4
- package/src/components/select.tsx +25 -8
- package/src/components/sheet.tsx +4 -3
- package/src/components/spinner.tsx +1 -1
- package/src/components/switch.tsx +31 -8
- package/src/components/table.tsx +121 -8
- package/src/components/tabs.tsx +29 -4
- package/src/components/tag.tsx +1 -1
- package/src/components/textarea.tsx +20 -3
- package/src/components/tooltip.tsx +14 -4
- package/src/components/tour.tsx +22 -7
- package/src/components/tree-view.tsx +1 -1
- package/src/components-behavior.test.tsx +74 -0
- package/src/components-direction-portal.test.tsx +482 -0
- package/src/components-form.test.tsx +435 -0
- package/src/components-icons.test.tsx +197 -0
- package/src/components-keyboard.test.tsx +542 -2
- package/src/components-overlay.test.tsx +381 -0
- package/src/components-rtl.test.tsx +258 -0
- package/src/components-selection.test.tsx +207 -0
- package/src/components-table-scroll.test.tsx +363 -0
- package/src/index.ts +2 -0
- package/src/navigation-menu-focus.test.tsx +35 -0
- package/src/stories/Card.stories.tsx +81 -1
- package/src/stories/Checkbox.stories.tsx +23 -1
- package/src/stories/ContextMenu.stories.tsx +1 -1
- package/src/stories/Input.stories.tsx +34 -0
- package/src/stories/InputGroup.stories.tsx +96 -2
- package/src/stories/InputOTP.stories.tsx +24 -1
- package/src/stories/MultiSelect.stories.tsx +25 -1
- package/src/stories/NativeSelect.stories.tsx +24 -1
- package/src/stories/NumberInput.stories.tsx +20 -1
- package/src/stories/Progress.stories.tsx +1 -1
- package/src/stories/RadioGroup.stories.tsx +33 -1
- package/src/stories/SegmentedControl.stories.tsx +21 -1
- package/src/stories/Select.stories.tsx +28 -1
- package/src/stories/Slider.stories.tsx +1 -1
- package/src/stories/Switch.stories.tsx +37 -1
- package/src/stories/Table.stories.tsx +35 -1
- package/src/stories/Tabs.stories.tsx +27 -2
- package/src/stories/Textarea.stories.tsx +34 -0
- package/src/usage/icons.tsx +60 -0
- package/tailwind.config.ts +118 -22
package/src/components/table.tsx
CHANGED
|
@@ -3,12 +3,103 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* How `TableCaption` and `Table` agree on the id that names the scroll container.
|
|
8
|
+
*
|
|
9
|
+
* `fallbackId` is a `useId` rather than a counter: stable across server and client render, and unique
|
|
10
|
+
* when several tables share a page. A caption given an explicit `id` by the caller keeps it.
|
|
11
|
+
*
|
|
12
|
+
* `register` is what keeps the two in step. The caption tells `Table` the id it actually ended up with,
|
|
13
|
+
* on mount, whenever that id changes, and again with `undefined` when it unmounts. The alternative —
|
|
14
|
+
* reading the caption's id out of the DOM while measuring — was wrong in a way review caught: a caller
|
|
15
|
+
* can change an `id` without changing any dimension (one derived from state, with the caption's text
|
|
16
|
+
* unchanged), no `ResizeObserver` callback fires, and the wrapper is left pointing `aria-labelledby` at
|
|
17
|
+
* an element that no longer exists. A dangling reference names nothing, so the focus stop goes
|
|
18
|
+
* unannounced. Registration follows the render that changed it, which is the thing that actually
|
|
19
|
+
* happened.
|
|
20
|
+
*/
|
|
21
|
+
type TableCaptionRegistration = {
|
|
22
|
+
fallbackId: string;
|
|
23
|
+
register: (id: string | undefined) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const TableCaptionContext = React.createContext<TableCaptionRegistration | undefined>(undefined);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True only while the scroll container can actually scroll horizontally.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately conditional. `overflow-auto` makes this wrapper a scrollable region, and a scrollable
|
|
32
|
+
* region no keyboard can reach is unreachable without a pointer — WCAG 2.1.1, which axe reports as
|
|
33
|
+
* `scrollable-region-focusable`. But most tables fit, and an unconditional `tabIndex` would add a focus
|
|
34
|
+
* stop to every table in every consumer's app that lands on nothing to scroll. So the tab stop exists
|
|
35
|
+
* exactly when there is something to scroll.
|
|
36
|
+
*
|
|
37
|
+
* This mirrors the `useScrollable` hook the docs site already uses for its preview frames, down to the
|
|
38
|
+
* 1px tolerance; it is re-stated here rather than imported because `packages/ui` cannot depend on the
|
|
39
|
+
* site, and it is kept module-private rather than exported so this fix adds no public API.
|
|
40
|
+
*
|
|
41
|
+
* The 1px tolerance is not superstition: sub-pixel layout rounding makes `scrollWidth` exceed
|
|
42
|
+
* `clientWidth` by a fraction on tables that are not actually scrollable, and that fraction would
|
|
43
|
+
* otherwise mint a useless focus stop.
|
|
44
|
+
*/
|
|
45
|
+
function useHorizontalScrollState(ref: React.RefObject<HTMLElement | null>) {
|
|
46
|
+
const [scrollable, setScrollable] = React.useState(false);
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
const el = ref.current;
|
|
50
|
+
if (!el) return;
|
|
51
|
+
|
|
52
|
+
const measure = () => setScrollable(el.scrollWidth > el.clientWidth + 1);
|
|
53
|
+
|
|
54
|
+
measure();
|
|
55
|
+
// ResizeObserver, not polling: the wrapper changes size when the viewport does, and the table changes
|
|
56
|
+
// size when its content or the reader's text size does. Observing both covers a responsive layout, new
|
|
57
|
+
// rows arriving, and a font-size change, none of which fire a resize event on the window.
|
|
58
|
+
const observer = new ResizeObserver(measure);
|
|
59
|
+
observer.observe(el);
|
|
60
|
+
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
|
61
|
+
return () => observer.disconnect();
|
|
62
|
+
}, [ref]);
|
|
63
|
+
|
|
64
|
+
return scrollable;
|
|
65
|
+
}
|
|
66
|
+
|
|
6
67
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
|
7
|
-
({ className, ...props }, ref) =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
68
|
+
({ className, ...props }, ref) => {
|
|
69
|
+
const wrapper = React.useRef<HTMLDivElement>(null);
|
|
70
|
+
const fallbackId = React.useId();
|
|
71
|
+
const [labelledBy, setLabelledBy] = React.useState<string>();
|
|
72
|
+
const scrollable = useHorizontalScrollState(wrapper);
|
|
73
|
+
// `setLabelledBy` is stable, so this is created once per table and never re-provides needlessly.
|
|
74
|
+
const caption = React.useMemo<TableCaptionRegistration>(
|
|
75
|
+
() => ({ fallbackId, register: setLabelledBy }),
|
|
76
|
+
[fallbackId],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={wrapper}
|
|
82
|
+
// The focus ring is the existing one — `ring-ring` resolves to the same token every other control
|
|
83
|
+
// in this library focuses with. Left on unconditionally: a div with no `tabIndex` never matches
|
|
84
|
+
// `:focus-visible`, so this costs nothing while keeping the class string stable across a resize.
|
|
85
|
+
className="relative w-full overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
86
|
+
{...(scrollable
|
|
87
|
+
? {
|
|
88
|
+
tabIndex: 0,
|
|
89
|
+
// Named only when the table actually has a caption to be named by. `role="group"` rather than
|
|
90
|
+
// `region`, which is a landmark and would put one entry per table into the landmark list; and
|
|
91
|
+
// no fabricated fallback name, because "Scrollable table" on every table in the catalogue
|
|
92
|
+
// tells a screen-reader user nothing they could not already hear from the table itself.
|
|
93
|
+
...(labelledBy ? { role: "group", "aria-labelledby": labelledBy } : {}),
|
|
94
|
+
}
|
|
95
|
+
: {})}
|
|
96
|
+
>
|
|
97
|
+
<TableCaptionContext.Provider value={caption}>
|
|
98
|
+
<table ref={ref} className={cn("w-full caption-bottom text-sm font-sans", className)} {...props} />
|
|
99
|
+
</TableCaptionContext.Provider>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
},
|
|
12
103
|
);
|
|
13
104
|
Table.displayName = "Table";
|
|
14
105
|
|
|
@@ -61,9 +152,31 @@ const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<
|
|
|
61
152
|
TableCell.displayName = "TableCell";
|
|
62
153
|
|
|
63
154
|
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
|
64
|
-
({ className, ...props }, ref) =>
|
|
65
|
-
|
|
66
|
-
|
|
155
|
+
({ className, id, ...props }, ref) => {
|
|
156
|
+
// Falls back to the id `Table` generated, so the scroll container has something real to point
|
|
157
|
+
// `aria-labelledby` at. A caller-supplied `id` always wins, so nothing that already sets one changes.
|
|
158
|
+
const context = React.useContext(TableCaptionContext);
|
|
159
|
+
const effectiveId = id ?? context?.fallbackId;
|
|
160
|
+
const register = context?.register;
|
|
161
|
+
|
|
162
|
+
// Report the id upward after every render that changes it, and withdraw it on unmount so a table
|
|
163
|
+
// whose caption is removed stops claiming a name instead of pointing at a missing element. A
|
|
164
|
+
// `TableCaption` rendered outside a `Table` has nothing to register with and simply renders.
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
if (!register) return;
|
|
167
|
+
register(effectiveId);
|
|
168
|
+
return () => register(undefined);
|
|
169
|
+
}, [register, effectiveId]);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<caption
|
|
173
|
+
ref={ref}
|
|
174
|
+
id={effectiveId}
|
|
175
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
176
|
+
{...props}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
},
|
|
67
180
|
);
|
|
68
181
|
TableCaption.displayName = "TableCaption";
|
|
69
182
|
|
package/src/components/tabs.tsx
CHANGED
|
@@ -4,6 +4,26 @@ import * as React from "react";
|
|
|
4
4
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Tabs — one navigation group, following the navigation state contract (TOKENS.md, "Text entry and
|
|
9
|
+
* navigation") and the surface model:
|
|
10
|
+
*
|
|
11
|
+
* list an inset well, `--surface-grouped` — below a Card in both themes; at large text sizes the tabs
|
|
12
|
+
* wrap inside it rather than running off the page
|
|
13
|
+
* rest unselected tabs in `--muted-foreground`
|
|
14
|
+
* hover an unselected tab gets the selection controls' `--foreground` state layer at 8% and
|
|
15
|
+
* `--foreground` text
|
|
16
|
+
* selected a small raised surface on the well — `--card`, the Card's half-strength `--border` edge and
|
|
17
|
+
* `sm` depth — and `--foreground` text, so it is surface + edge + depth, never colour alone. The
|
|
18
|
+
* list used to be `--muted` with a `--background` tab, which in dark mode put the selected tab
|
|
19
|
+
* BELOW its well (measured: tab L 0.0031, well L 0.0167) — the inversion SegmentedControl had
|
|
20
|
+
* focus the 2px `--ring`, `!important` so the selected tab's edge cannot cover it; the selected
|
|
21
|
+
* surface stays under it
|
|
22
|
+
* disabled `--opacity-disabled`, inert
|
|
23
|
+
*
|
|
24
|
+
* There is no pressed state: Radix selects a tab on pointer-down, so pressing IS selecting.
|
|
25
|
+
* Motion: colour, background and box-shadow over `duration-fast` with `ease-standard`; nothing moves.
|
|
26
|
+
*/
|
|
7
27
|
const Tabs = TabsPrimitive.Root;
|
|
8
28
|
|
|
9
29
|
const TabsList = React.forwardRef<
|
|
@@ -13,7 +33,10 @@ const TabsList = React.forwardRef<
|
|
|
13
33
|
<TabsPrimitive.List
|
|
14
34
|
ref={ref}
|
|
15
35
|
className={cn(
|
|
16
|
-
|
|
36
|
+
// `min-h-9` and `flex-wrap`, not `h-9`: identical at the default text size, but at 200% text three
|
|
37
|
+
// tabs need 440px and a phone gives them 358 — the strip ran 82px off the page (large-text pass), and a
|
|
38
|
+
// fixed-height well would have clipped a second row. Now the tabs wrap inside the well and it grows.
|
|
39
|
+
"inline-flex min-h-9 max-w-full flex-wrap items-center justify-center rounded-lg bg-surface-grouped p-1 text-muted-foreground font-sans",
|
|
17
40
|
className,
|
|
18
41
|
)}
|
|
19
42
|
{...props}
|
|
@@ -28,10 +51,12 @@ const TabsTrigger = React.forwardRef<
|
|
|
28
51
|
<TabsPrimitive.Trigger
|
|
29
52
|
ref={ref}
|
|
30
53
|
className={cn(
|
|
31
|
-
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium
|
|
32
|
-
"
|
|
54
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium",
|
|
55
|
+
"transition-[color,background-color,box-shadow] duration-fast ease-standard",
|
|
56
|
+
"[@media(hover:hover)]:enabled:hover:data-[state=inactive]:bg-foreground/[0.08] [@media(hover:hover)]:enabled:hover:data-[state=inactive]:text-foreground",
|
|
57
|
+
"data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:[box-shadow:inset_0_0_0_1px_hsl(var(--border)/0.5),var(--shadow-sm)]",
|
|
58
|
+
"focus-visible:outline-none focus-visible:!ring-2 focus-visible:ring-ring",
|
|
33
59
|
"disabled:pointer-events-none disabled:opacity-disabled",
|
|
34
|
-
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
|
35
60
|
className,
|
|
36
61
|
)}
|
|
37
62
|
{...props}
|
package/src/components/tag.tsx
CHANGED
|
@@ -56,7 +56,7 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
56
56
|
type="button"
|
|
57
57
|
onClick={onRemove}
|
|
58
58
|
aria-label={name}
|
|
59
|
-
className="-
|
|
59
|
+
className="-me-0.5 ms-0.5 rounded-[2px] opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:ring-1 focus-visible:ring-current"
|
|
60
60
|
>
|
|
61
61
|
<X className="size-3.5" />
|
|
62
62
|
</button>
|
|
@@ -9,14 +9,31 @@ import { cn } from "../lib/utils";
|
|
|
9
9
|
* 01. Form Inputs › Textarea (node 54855:13857).
|
|
10
10
|
*
|
|
11
11
|
* Identical to `Input` except the field is multi-line: `min-h-[100px]`
|
|
12
|
-
* (Figma `h: 100`) and vertical resize. Same state matrix and tokens
|
|
12
|
+
* (Figma `h: 100`) and vertical resize. Same state matrix and tokens as Input:
|
|
13
|
+
*
|
|
14
|
+
* It follows the text-entry state contract (TOKENS.md, "Text entry and navigation"):
|
|
15
|
+
*
|
|
16
|
+
* rest edge `--muted-foreground` at 80% — 3.4:1 light / 5.4:1 dark on a card, where `--input` was
|
|
17
|
+
* 2.2:1 / 2.4:1 and was the only thing marking where the field is (SC 1.4.11)
|
|
18
|
+
* hover a pointer that can hover: the edge steps to full `--muted-foreground`. Not on a focused,
|
|
19
|
+
* invalid, read-only or disabled field — hover never takes over a stronger state
|
|
20
|
+
* focus `--action` edge + the `--shadow-focus` ring, the strongest signal, unchanged by the pointer
|
|
21
|
+
* invalid `aria-invalid="true"` → `--destructive` edge and ring, which hover does not replace; the
|
|
22
|
+
* field's error text is the non-colour cue (SC 1.4.1)
|
|
23
|
+
* read-only an inset `--muted` fill, full-strength text, no hover: readable and selectable, not editable
|
|
24
|
+
* disabled `--opacity-disabled`, inert
|
|
25
|
+
*
|
|
26
|
+
* Motion: border-color, box-shadow and background-color over `duration-instant` with `ease-standard`.
|
|
27
|
+
* Reduced motion collapses it (the library floor) and lands on the same end state.
|
|
13
28
|
*/
|
|
14
29
|
const textareaVariants = cva(
|
|
15
30
|
[
|
|
16
|
-
"flex w-full min-h-[100px] resize-y rounded-sm border border-
|
|
31
|
+
"flex w-full min-h-[100px] resize-y rounded-sm border border-muted-foreground/80 bg-background px-3 py-3",
|
|
17
32
|
"font-sans text-body-md text-foreground",
|
|
18
33
|
"placeholder:text-muted-foreground",
|
|
19
|
-
"outline-none transition-
|
|
34
|
+
"outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
|
|
35
|
+
"[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([readonly]):not([data-state=focus])]:border-muted-foreground",
|
|
36
|
+
"[&[readonly]]:bg-muted",
|
|
20
37
|
"focus-visible:border-action focus-visible:shadow-focus",
|
|
21
38
|
"disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
22
39
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { withDirectionalPortal } from "./direction-provider";
|
|
6
7
|
|
|
8
|
+
const TooltipPortal = withDirectionalPortal(TooltipPrimitive.Portal);
|
|
7
9
|
const TooltipProvider = TooltipPrimitive.Provider;
|
|
8
10
|
const Tooltip = TooltipPrimitive.Root;
|
|
9
11
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
@@ -12,19 +14,27 @@ const TooltipContent = React.forwardRef<
|
|
|
12
14
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
13
15
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
14
16
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
15
|
-
<
|
|
17
|
+
<TooltipPortal>
|
|
16
18
|
<TooltipPrimitive.Content
|
|
17
19
|
ref={ref}
|
|
18
20
|
sideOffset={sideOffset}
|
|
19
21
|
className={cn(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// Capped to the room Radix reports it has, so large text cannot push the surface past the window —
|
|
23
|
+
// see the note in popover.tsx for the measurement that prompted it.
|
|
24
|
+
"z-overlay max-w-[var(--radix-popper-available-width)] overflow-hidden rounded-md bg-action px-3 py-1.5 text-xs text-action-foreground font-sans",
|
|
25
|
+
// Timing stays in the same string as the animation it times, so a reader (and the motion
|
|
26
|
+
// contract test) can see both at once.
|
|
27
|
+
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 duration-instant ease-enter data-[state=closed]:ease-exit",
|
|
28
|
+
// `data-side` is the side Radix RESOLVED the surface onto — it is already physical, having been
|
|
29
|
+
// flipped for direction and collisions before it reaches the DOM. The slide must therefore stay
|
|
30
|
+
// physical to travel away from the trigger; a logical class here would invert the animation under
|
|
31
|
+
// RTL and make the surface fly the wrong way. // rtl-ok
|
|
22
32
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
23
33
|
className,
|
|
24
34
|
)}
|
|
25
35
|
{...props}
|
|
26
36
|
/>
|
|
27
|
-
</
|
|
37
|
+
</TooltipPortal>
|
|
28
38
|
));
|
|
29
39
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
30
40
|
|
package/src/components/tour.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { Button } from "./button";
|
|
6
|
+
import { useKinetixPortalContainer } from "./direction-provider";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Tour — sequenced spotlight popovers over real elements (onboarding
|
|
@@ -38,6 +39,13 @@ export interface TourProps {
|
|
|
38
39
|
|
|
39
40
|
const SPOTLIGHT_PADDING = 8;
|
|
40
41
|
|
|
42
|
+
/** `true` when the user asked for less motion. Safe on the server and anywhere `matchMedia` is absent. */
|
|
43
|
+
function prefersReducedMotion(): boolean {
|
|
44
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
45
|
+
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
46
|
+
: false;
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
const Tour: React.FC<TourProps> = ({
|
|
42
50
|
steps,
|
|
43
51
|
open,
|
|
@@ -50,6 +58,9 @@ const Tour: React.FC<TourProps> = ({
|
|
|
50
58
|
doneLabel = "Done",
|
|
51
59
|
}) => {
|
|
52
60
|
const [mounted, setMounted] = React.useState(false);
|
|
61
|
+
// Portaled like every other overlay here: into the nearest direction provider's host, else <body>. `null`
|
|
62
|
+
// is a provider whose host is not attached yet: render nothing until it is.
|
|
63
|
+
const portalHost = useKinetixPortalContainer();
|
|
53
64
|
const [rect, setRect] = React.useState<DOMRect | null>(null);
|
|
54
65
|
const step = steps[stepIndex];
|
|
55
66
|
|
|
@@ -98,7 +109,11 @@ const Tour: React.FC<TourProps> = ({
|
|
|
98
109
|
const el = document.querySelector(step.target);
|
|
99
110
|
if (el) {
|
|
100
111
|
setRect(el.getBoundingClientRect());
|
|
101
|
-
|
|
112
|
+
// An explicit `behavior: "smooth"` overrides the CSS `scroll-behavior: auto` that the
|
|
113
|
+
// reduced-motion base layer sets, so the option has to be chosen rather than inherited:
|
|
114
|
+
// otherwise the one motion a reduced-motion user cannot look away from — the viewport
|
|
115
|
+
// itself travelling — is the one that still plays.
|
|
116
|
+
el.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" });
|
|
102
117
|
} else {
|
|
103
118
|
setRect(null);
|
|
104
119
|
}
|
|
@@ -121,7 +136,7 @@ const Tour: React.FC<TourProps> = ({
|
|
|
121
136
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
122
137
|
}, [open, onOpenChange]);
|
|
123
138
|
|
|
124
|
-
if (!mounted || !open || !step) return null;
|
|
139
|
+
if (!mounted || !open || !step || portalHost === null) return null;
|
|
125
140
|
|
|
126
141
|
const isFirst = stepIndex === 0;
|
|
127
142
|
const isLast = stepIndex === steps.length - 1;
|
|
@@ -132,9 +147,9 @@ const Tour: React.FC<TourProps> = ({
|
|
|
132
147
|
left: rect.left - SPOTLIGHT_PADDING,
|
|
133
148
|
width: rect.width + SPOTLIGHT_PADDING * 2,
|
|
134
149
|
height: rect.height + SPOTLIGHT_PADDING * 2,
|
|
135
|
-
boxShadow: "0 0 0 9999px
|
|
150
|
+
boxShadow: "0 0 0 9999px hsl(var(--scrim))",
|
|
136
151
|
}
|
|
137
|
-
: { inset: 0, background: "
|
|
152
|
+
: { inset: 0, background: "hsl(var(--scrim))" };
|
|
138
153
|
|
|
139
154
|
const cardStyle: React.CSSProperties = rect
|
|
140
155
|
? {
|
|
@@ -147,7 +162,7 @@ const Tour: React.FC<TourProps> = ({
|
|
|
147
162
|
<>
|
|
148
163
|
<div
|
|
149
164
|
aria-hidden
|
|
150
|
-
className="pointer-events-none fixed z-overlay rounded-lg transition-
|
|
165
|
+
className="pointer-events-none fixed z-overlay rounded-lg transition-[top,left,width,height,opacity] duration-fast ease-standard"
|
|
151
166
|
style={spotlightStyle}
|
|
152
167
|
/>
|
|
153
168
|
<div
|
|
@@ -157,7 +172,7 @@ const Tour: React.FC<TourProps> = ({
|
|
|
157
172
|
aria-labelledby={titleId}
|
|
158
173
|
tabIndex={-1}
|
|
159
174
|
onKeyDown={trapTab}
|
|
160
|
-
className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg outline-none transition-
|
|
175
|
+
className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg outline-none transition-[top,left,opacity] duration-fast ease-standard"
|
|
161
176
|
style={cardStyle}
|
|
162
177
|
>
|
|
163
178
|
<p id={titleId} className="text-title-sm font-medium text-foreground">
|
|
@@ -192,7 +207,7 @@ const Tour: React.FC<TourProps> = ({
|
|
|
192
207
|
</div>
|
|
193
208
|
</div>
|
|
194
209
|
</>,
|
|
195
|
-
document.body,
|
|
210
|
+
portalHost ?? document.body,
|
|
196
211
|
);
|
|
197
212
|
};
|
|
198
213
|
|
|
@@ -262,7 +262,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
262
262
|
>
|
|
263
263
|
{hasChildren ? (
|
|
264
264
|
<ChevronRight
|
|
265
|
-
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded
|
|
265
|
+
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded ? "rotate-90" : "rtl:-scale-x-100")}
|
|
266
266
|
onClick={(e) => {
|
|
267
267
|
e.stopPropagation();
|
|
268
268
|
ctx.toggleExpanded(value);
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AccordionItem,
|
|
13
13
|
AccordionTrigger,
|
|
14
14
|
} from "./components/accordion";
|
|
15
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./components/collapsible";
|
|
15
16
|
|
|
16
17
|
// kx-verify: interaction
|
|
17
18
|
|
|
@@ -137,3 +138,76 @@ describe("Accordion", () => {
|
|
|
137
138
|
expect(screen.getByText("Answer text")).toBeVisible();
|
|
138
139
|
});
|
|
139
140
|
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Collapsible's disclosure contract.
|
|
144
|
+
*
|
|
145
|
+
* These assert the behaviour and the structural prerequisites for the transition — that the content
|
|
146
|
+
* is clipped while its height animates, and that it carries the state attribute the animation is
|
|
147
|
+
* selected on. They deliberately do NOT assert that it moved: jsdom has no layout and no compositor,
|
|
148
|
+
* so a test here cannot tell a 200ms height animation from an instant one. That claim is made by
|
|
149
|
+
* `scripts/motion.mjs`, in a browser, by reading a rendered midpoint.
|
|
150
|
+
*
|
|
151
|
+
* The split matters because the component shipped with no tests at all and no motion, and a test
|
|
152
|
+
* asserting a class name would have gone on passing if the keyframes were deleted.
|
|
153
|
+
*/
|
|
154
|
+
describe("Collapsible", () => {
|
|
155
|
+
const Example = () => (
|
|
156
|
+
<Collapsible>
|
|
157
|
+
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
|
|
158
|
+
<CollapsibleContent>Hidden detail</CollapsibleContent>
|
|
159
|
+
</Collapsible>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
it("reveals and hides its content from the trigger", async () => {
|
|
163
|
+
render(<Example />);
|
|
164
|
+
expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
|
|
165
|
+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
|
|
166
|
+
expect(screen.getByText("Hidden detail")).toBeVisible();
|
|
167
|
+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
|
|
168
|
+
expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("opens from the keyboard, because the trigger is a real button", async () => {
|
|
172
|
+
render(<Example />);
|
|
173
|
+
await userEvent.tab();
|
|
174
|
+
expect(screen.getByRole("button", { name: "Toggle" })).toHaveFocus();
|
|
175
|
+
await userEvent.keyboard("{Enter}");
|
|
176
|
+
expect(screen.getByText("Hidden detail")).toBeVisible();
|
|
177
|
+
await userEvent.keyboard(" ");
|
|
178
|
+
expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("clips its content while the height animates", async () => {
|
|
182
|
+
render(<Example />);
|
|
183
|
+
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
|
|
184
|
+
// Without overflow-hidden the text is laid out at full height from the first frame and spills
|
|
185
|
+
// out of the box the animation is still collapsing.
|
|
186
|
+
//
|
|
187
|
+
// `getByText` returns the content element itself: unlike AccordionContent, this component wraps
|
|
188
|
+
// its children in nothing, so the animated element and the text's element are the same node.
|
|
189
|
+
expect(screen.getByText("Hidden detail")).toHaveClass("overflow-hidden");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("carries the state attribute the disclosure animation is selected on", async () => {
|
|
193
|
+
render(<Example />);
|
|
194
|
+
const trigger = screen.getByRole("button", { name: "Toggle" });
|
|
195
|
+
await userEvent.click(trigger);
|
|
196
|
+
const content = screen.getByText("Hidden detail");
|
|
197
|
+
expect(content).toHaveAttribute("data-state", "open");
|
|
198
|
+
expect(content?.className).toContain("data-[state=open]:animate-collapsible-down");
|
|
199
|
+
expect(content?.className).toContain("data-[state=closed]:animate-collapsible-up");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("merges a caller's className instead of dropping it", async () => {
|
|
203
|
+
render(
|
|
204
|
+
<Collapsible defaultOpen>
|
|
205
|
+
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
|
|
206
|
+
<CollapsibleContent className="mt-4">Hidden detail</CollapsibleContent>
|
|
207
|
+
</Collapsible>,
|
|
208
|
+
);
|
|
209
|
+
const content = screen.getByText("Hidden detail");
|
|
210
|
+
expect(content).toHaveClass("mt-4");
|
|
211
|
+
expect(content).toHaveClass("overflow-hidden");
|
|
212
|
+
});
|
|
213
|
+
});
|