@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
|
@@ -75,20 +75,29 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
75
75
|
tabIndex={0}
|
|
76
76
|
onClick={() => setOpen(true)}
|
|
77
77
|
onKeyDown={(e) => {
|
|
78
|
-
|
|
78
|
+
// Enter and Space open it, and so do the vertical arrows: WAI-ARIA's combobox pattern lists
|
|
79
|
+
// Down Arrow as a way to open the popup, and a keyboard user who has just tabbed onto a
|
|
80
|
+
// collapsed combobox reaches for it first. Without it the arrows did nothing at all here —
|
|
81
|
+
// the only way in was Enter or Space.
|
|
82
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
79
83
|
e.preventDefault();
|
|
80
84
|
setOpen(true);
|
|
81
85
|
}
|
|
82
86
|
}}
|
|
83
87
|
className={cn(
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// The text-entry state contract (TOKENS.md, "Composite fields"): one field, whose chips sit inside
|
|
89
|
+
// its edge. `rounded-sm` is the field radius Input and Select already use; this was square.
|
|
90
|
+
"flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-sm border border-muted-foreground/80 bg-background px-3 py-2 font-sans",
|
|
91
|
+
"outline-none transition-[border-color,box-shadow] duration-instant ease-standard",
|
|
92
|
+
"[@media(hover:hover)]:hover:[&:not(:focus):not([aria-expanded=true]):not([aria-invalid=true])]:border-muted-foreground",
|
|
93
|
+
"focus-visible:border-action focus-visible:shadow-focus",
|
|
86
94
|
"aria-expanded:border-action aria-expanded:shadow-focus",
|
|
95
|
+
"aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive aria-[invalid=true]:aria-expanded:shadow-focus-destructive",
|
|
87
96
|
className,
|
|
88
97
|
)}
|
|
89
98
|
{...props}
|
|
90
99
|
>
|
|
91
|
-
{current.length === 0 && <span className="text-body-
|
|
100
|
+
{current.length === 0 && <span className="text-body-md text-muted-foreground">{placeholder}</span>}
|
|
92
101
|
{current.map((v) => (
|
|
93
102
|
<Tag key={v} variant="secondary" onRemove={() => remove(v)} onClick={(e) => e.stopPropagation()}>
|
|
94
103
|
{labelFor(v)}
|
|
@@ -125,7 +134,7 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
125
134
|
{canCreate ? (
|
|
126
135
|
<button
|
|
127
136
|
type="button"
|
|
128
|
-
className="flex w-full items-center px-2 py-1.5 text-
|
|
137
|
+
className="flex w-full items-center px-2 py-1.5 text-start text-body-sm text-foreground hover:bg-accent"
|
|
129
138
|
onClick={() => {
|
|
130
139
|
toggle(trimmed);
|
|
131
140
|
setQuery("");
|
|
@@ -12,12 +12,20 @@ import { cn } from "../lib/utils";
|
|
|
12
12
|
* Matches Input's `inputVariants` styling 1:1 so the two are drop-in
|
|
13
13
|
* interchangeable in a Field. Gap-fill addition (not in the original
|
|
14
14
|
* Figma source) — matches the shadcn/ui Native Select API shape.
|
|
15
|
+
*
|
|
16
|
+
* Same text-entry state contract as Input (TOKENS.md, "Text entry and navigation") — rest edge, hover,
|
|
17
|
+
* focus, invalid, disabled — minus read-only, which `<select>` does not have. Its placeholder is the
|
|
18
|
+
* empty-valued option: while it is the selection the field's text is `--muted-foreground`, so an unanswered
|
|
19
|
+
* select reads as unanswered next to a filled one (it used to render in the value's colour). The options in
|
|
20
|
+
* the open picker keep `--foreground`.
|
|
15
21
|
*/
|
|
16
22
|
const nativeSelectVariants = cva(
|
|
17
23
|
[
|
|
18
|
-
"flex w-full appearance-none border border-
|
|
24
|
+
"flex w-full appearance-none border border-muted-foreground/80 bg-background px-3 py-3 pe-9",
|
|
19
25
|
"font-sans text-body-md text-foreground",
|
|
20
|
-
"
|
|
26
|
+
"[&:has(option[value='']:checked)]:text-muted-foreground [&_option]:text-foreground",
|
|
27
|
+
"outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
|
|
28
|
+
"[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([readonly]):not([data-state=focus])]:border-muted-foreground",
|
|
21
29
|
"focus-visible:border-action focus-visible:shadow-focus",
|
|
22
30
|
"disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
23
31
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
|
|
@@ -32,7 +32,7 @@ const NavigationBar = React.forwardRef<HTMLElement, NavigationBarProps>(
|
|
|
32
32
|
aria-label="Back"
|
|
33
33
|
className="flex size-9 items-center justify-center rounded-full text-foreground outline-none transition-colors hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
|
|
34
34
|
>
|
|
35
|
-
<ChevronLeft className="size-5" />
|
|
35
|
+
<ChevronLeft className="size-5 rtl:-scale-x-100" />
|
|
36
36
|
</button>
|
|
37
37
|
))}
|
|
38
38
|
</div>
|
|
@@ -36,7 +36,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
|
|
36
36
|
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
|
37
37
|
|
|
38
38
|
const navigationMenuTriggerStyle = cva(
|
|
39
|
-
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-disabled data-[state=open]:bg-accent/50",
|
|
39
|
+
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled data-[state=open]:bg-accent/50",
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
const NavigationMenuTrigger = React.forwardRef<
|
|
@@ -46,7 +46,7 @@ const NavigationMenuTrigger = React.forwardRef<
|
|
|
46
46
|
<NavigationMenuPrimitive.Trigger ref={ref} className={cn(navigationMenuTriggerStyle(), "group", className)} {...props}>
|
|
47
47
|
{children}
|
|
48
48
|
<ChevronDown
|
|
49
|
-
className="relative top-px
|
|
49
|
+
className="relative top-px ms-1 size-3 transition-transform duration-fast group-data-[state=open]:rotate-180"
|
|
50
50
|
aria-hidden
|
|
51
51
|
/>
|
|
52
52
|
</NavigationMenuPrimitive.Trigger>
|
|
@@ -60,7 +60,7 @@ const NavigationMenuContent = React.forwardRef<
|
|
|
60
60
|
<NavigationMenuPrimitive.Content
|
|
61
61
|
ref={ref}
|
|
62
62
|
className={cn(
|
|
63
|
-
"
|
|
63
|
+
"start-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion^=from-]:duration-fast data-[motion^=from-]:ease-enter data-[motion^=to-]:duration-instant data-[motion^=to-]:ease-exit md:absolute md:w-auto",
|
|
64
64
|
className,
|
|
65
65
|
)}
|
|
66
66
|
{...props}
|
|
@@ -74,11 +74,11 @@ const NavigationMenuViewport = React.forwardRef<
|
|
|
74
74
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
|
75
75
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
|
76
76
|
>(({ className, ...props }, ref) => (
|
|
77
|
-
<div className="absolute
|
|
77
|
+
<div className="absolute start-0 top-full flex justify-center">
|
|
78
78
|
<NavigationMenuPrimitive.Viewport
|
|
79
79
|
ref={ref}
|
|
80
80
|
className={cn(
|
|
81
|
-
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
81
|
+
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
82
82
|
className,
|
|
83
83
|
)}
|
|
84
84
|
{...props}
|
|
@@ -94,7 +94,7 @@ const NavigationMenuIndicator = React.forwardRef<
|
|
|
94
94
|
<NavigationMenuPrimitive.Indicator
|
|
95
95
|
ref={ref}
|
|
96
96
|
className={cn(
|
|
97
|
-
"top-full z-raised flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
|
97
|
+
"top-full z-raised flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in data-[state=visible]:duration-fast data-[state=visible]:ease-enter data-[state=hidden]:duration-instant data-[state=hidden]:ease-exit",
|
|
98
98
|
className,
|
|
99
99
|
)}
|
|
100
100
|
{...props}
|
|
@@ -6,9 +6,19 @@ import { cn } from "../lib/utils";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* NumberInput — a numeric field with increment / decrement controls.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* The wrapper is ONE field and follows the text-entry state contract (TOKENS.md, "Composite fields"); the
|
|
11
|
+
* steppers sit inside its edge, separated by quiet internal dividers rather than borders of their own.
|
|
12
|
+
*
|
|
13
|
+
* rest edge `--muted-foreground` at 80% (3:1 on a card; `--input` was 2.2:1)
|
|
14
|
+
* hover the edge steps to full `--muted-foreground` — never over focus, invalid, read-only or disabled
|
|
15
|
+
* focus the INPUT focused → `--action` edge + `--shadow-focus` on the wrapper. A stepper focused → its
|
|
16
|
+
* own inset `--ring`, and the wrapper stays at rest, so the ring says which part has focus
|
|
17
|
+
* invalid `aria-invalid="true"` (passed through to the input) → `--destructive` edge, kept under the
|
|
18
|
+
* pointer and focus
|
|
19
|
+
* read-only `readOnly` → the inset `--muted` fill, and the steppers are disabled: a read-only value is
|
|
20
|
+
* not one a button may change either
|
|
21
|
+
* disabled `--opacity-disabled`, inert
|
|
12
22
|
*/
|
|
13
23
|
export interface NumberInputProps
|
|
14
24
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange"> {
|
|
@@ -21,7 +31,7 @@ export interface NumberInputProps
|
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
24
|
-
({ className, value, defaultValue = 0, min, max, step = 1, onChange, disabled, ...props }, ref) => {
|
|
34
|
+
({ className, value, defaultValue = 0, min, max, step = 1, onChange, disabled, readOnly, ...props }, ref) => {
|
|
25
35
|
const [internal, setInternal] = React.useState(defaultValue);
|
|
26
36
|
const current = value ?? internal;
|
|
27
37
|
|
|
@@ -41,7 +51,14 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
41
51
|
return (
|
|
42
52
|
<div
|
|
43
53
|
className={cn(
|
|
44
|
-
|
|
54
|
+
// no fixed height: the input's own padding sets it, so it matches Input and InputGroup in the same row
|
|
55
|
+
// (it was a fixed 40px beside their 46px) and still grows with the text
|
|
56
|
+
"flex items-stretch overflow-hidden rounded-md border border-muted-foreground/80 bg-background font-sans",
|
|
57
|
+
"transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
|
|
58
|
+
!disabled && "[@media(hover:hover)]:hover:[&:not(:has(input:is(:focus,[readonly],[aria-invalid=true])))]:border-muted-foreground",
|
|
59
|
+
"has-[input[readonly]]:bg-muted",
|
|
60
|
+
"has-[input:focus:not([aria-invalid=true])]:border-action has-[input:focus]:shadow-focus",
|
|
61
|
+
"has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]:focus]:shadow-focus-destructive",
|
|
45
62
|
disabled && "pointer-events-none opacity-disabled",
|
|
46
63
|
className,
|
|
47
64
|
)}
|
|
@@ -49,9 +66,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
49
66
|
<button
|
|
50
67
|
type="button"
|
|
51
68
|
aria-label="Decrease"
|
|
52
|
-
disabled={disabled || (min != null && current <= min)}
|
|
69
|
+
disabled={disabled || readOnly || (min != null && current <= min)}
|
|
53
70
|
onClick={() => set(current - step)}
|
|
54
|
-
className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-
|
|
71
|
+
className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
|
|
55
72
|
>
|
|
56
73
|
<Minus className="size-4" />
|
|
57
74
|
</button>
|
|
@@ -64,19 +81,20 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
|
64
81
|
max={max}
|
|
65
82
|
step={step}
|
|
66
83
|
disabled={disabled}
|
|
84
|
+
readOnly={readOnly}
|
|
67
85
|
onChange={(e) => {
|
|
68
86
|
const n = e.target.valueAsNumber;
|
|
69
87
|
if (!Number.isNaN(n)) set(n);
|
|
70
88
|
}}
|
|
71
|
-
className="w-full min-w-0 flex-1 border-x border-input bg-transparent px-2 text-center text-body-md text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
89
|
+
className="w-full min-w-0 flex-1 border-x border-input bg-transparent px-2 py-3 text-center text-body-md text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
72
90
|
{...props}
|
|
73
91
|
/>
|
|
74
92
|
<button
|
|
75
93
|
type="button"
|
|
76
94
|
aria-label="Increase"
|
|
77
|
-
disabled={disabled || (max != null && current >= max)}
|
|
95
|
+
disabled={disabled || readOnly || (max != null && current >= max)}
|
|
78
96
|
onClick={() => set(current + step)}
|
|
79
|
-
className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-
|
|
97
|
+
className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
|
|
80
98
|
>
|
|
81
99
|
<Plus className="size-4" />
|
|
82
100
|
</button>
|
|
@@ -44,7 +44,7 @@ PaginationLink.displayName = "PaginationLink";
|
|
|
44
44
|
|
|
45
45
|
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
|
46
46
|
<PaginationLink aria-label="Go to previous page" className={cn("w-auto gap-1 px-2.5", className)} {...props}>
|
|
47
|
-
<ChevronLeft className="size-4" />
|
|
47
|
+
<ChevronLeft className="size-4 rtl:-scale-x-100" />
|
|
48
48
|
<span>Previous</span>
|
|
49
49
|
</PaginationLink>
|
|
50
50
|
);
|
|
@@ -53,7 +53,7 @@ PaginationPrevious.displayName = "PaginationPrevious";
|
|
|
53
53
|
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
|
54
54
|
<PaginationLink aria-label="Go to next page" className={cn("w-auto gap-1 px-2.5", className)} {...props}>
|
|
55
55
|
<span>Next</span>
|
|
56
|
-
<ChevronRight className="size-4" />
|
|
56
|
+
<ChevronRight className="size-4 rtl:-scale-x-100" />
|
|
57
57
|
</PaginationLink>
|
|
58
58
|
);
|
|
59
59
|
PaginationNext.displayName = "PaginationNext";
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { withDirectionalPortal } from "./direction-provider";
|
|
6
7
|
|
|
8
|
+
const PopoverPortal = withDirectionalPortal(PopoverPrimitive.Portal);
|
|
7
9
|
const Popover = PopoverPrimitive.Root;
|
|
8
10
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
9
11
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
@@ -11,21 +13,43 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
|
11
13
|
const PopoverContent = React.forwardRef<
|
|
12
14
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
13
15
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
14
|
-
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
|
15
|
-
<
|
|
16
|
+
>(({ className, align = "center", sideOffset = 4, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }, ref) => (
|
|
17
|
+
<PopoverPortal>
|
|
16
18
|
<PopoverPrimitive.Content
|
|
17
19
|
ref={ref}
|
|
18
20
|
align={align}
|
|
19
21
|
sideOffset={sideOffset}
|
|
22
|
+
// Radix gives this `role="dialog"`, and a dialog without an accessible name is announced as just
|
|
23
|
+
// "dialog" — WCAG 4.1.2. Nothing here named it, so every Popover in the library shipped that way:
|
|
24
|
+
// found by running axe against the surface while it was open, which until this slice nothing did.
|
|
25
|
+
// The fallback is deliberately only a fallback. A caller who passes `aria-label` or points at a
|
|
26
|
+
// heading with `aria-labelledby` keeps theirs, and should: "Popover" tells a screen-reader user
|
|
27
|
+
// what kind of thing opened and nothing about what is in it. It is the floor, not the goal.
|
|
28
|
+
// Both naming props are destructured OUT of the spread, not read from it. Read from `props` with
|
|
29
|
+
// `{...props}` trailing, `aria-label={label}` with `label` undefined — the ordinary conditional-prop
|
|
30
|
+
// spelling — spreads the key back as undefined and deletes the attribute, so the fallback computed
|
|
31
|
+
// here would be erased and the dialog would ship unnamed again by the very pattern it guards.
|
|
32
|
+
aria-labelledby={ariaLabelledBy}
|
|
33
|
+
aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : "Popover")}
|
|
20
34
|
className={cn(
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
// A surface wider than the window is not usable. Measured at 390px with the root font size
|
|
36
|
+
// doubled: this came out 576px, because `w-72` is rem and doubles with the text, and the
|
|
37
|
+
// context menu 416px — pushing the page 186px sideways, so a reader who asked for large text
|
|
38
|
+
// had to scroll horizontally to see a popover. Radix publishes the room it has as
|
|
39
|
+
// `--radix-popper-available-width`, so the cap is its own measurement rather than a viewport
|
|
40
|
+
// guess, and it binds only when the surface would otherwise overflow.
|
|
41
|
+
"z-overlay w-72 max-w-[var(--radix-popper-available-width)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none font-sans",
|
|
42
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
|
|
43
|
+
// `data-side` is the side Radix RESOLVED the surface onto — it is already physical, having been
|
|
44
|
+
// flipped for direction and collisions before it reaches the DOM. The slide must therefore stay
|
|
45
|
+
// physical to travel away from the trigger; a logical class here would invert the animation under
|
|
46
|
+
// RTL and make the surface fly the wrong way. // rtl-ok
|
|
23
47
|
"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",
|
|
24
48
|
className,
|
|
25
49
|
)}
|
|
26
50
|
{...props}
|
|
27
51
|
/>
|
|
28
|
-
</
|
|
52
|
+
</PopoverPortal>
|
|
29
53
|
));
|
|
30
54
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
31
55
|
|
|
@@ -6,9 +6,13 @@ import { Circle } from "lucide-react";
|
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* RadioGroup —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* RadioGroup — from the KinetixUI design source, node 54863:536: 18px circle, 2px border, checked border +
|
|
10
|
+
* dot = `--action`, focus = 2px `--ring` offset.
|
|
11
|
+
*
|
|
12
|
+
* Each item follows the selection-control state contract (TOKENS.md, "Selection controls"), exactly as
|
|
13
|
+
* Checkbox does: a `--muted-foreground` unchecked edge that clears 3:1, an 8% `--foreground` state layer on
|
|
14
|
+
* hover (14% pressed), a dot as the shape cue for checked, `aria-invalid="true"` → `--destructive` edge and
|
|
15
|
+
* dot, a focus ring no other layer can cover, and no response at all while disabled.
|
|
12
16
|
*/
|
|
13
17
|
const RadioGroup = React.forwardRef<
|
|
14
18
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
@@ -25,17 +29,28 @@ const RadioGroupItem = React.forwardRef<
|
|
|
25
29
|
<RadioGroupPrimitive.Item
|
|
26
30
|
ref={ref}
|
|
27
31
|
className={cn(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
32
|
+
// rem rather than px, for the reason spelled out in checkbox.tsx: at a 200% default font size the
|
|
33
|
+
// 18px dot did not grow while its label did.
|
|
34
|
+
"aspect-square size-[1.125rem] rounded-full border-2 border-muted-foreground text-action outline-none",
|
|
35
|
+
"transition-[color,border-color,box-shadow] duration-instant ease-standard",
|
|
31
36
|
"data-[state=checked]:border-action",
|
|
32
|
-
|
|
37
|
+
// `aria-[invalid=true]:`, not `aria-invalid:`, which Tailwind 3 does not define (see checkbox.tsx).
|
|
38
|
+
// `text-` carries the dot, which is `fill-current`.
|
|
39
|
+
"aria-[invalid=true]:border-destructive aria-[invalid=true]:text-destructive",
|
|
40
|
+
"[@media(hover:hover)]:enabled:hover:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.08)]",
|
|
41
|
+
// Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
|
|
42
|
+
// emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
|
|
43
|
+
"enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
|
|
44
|
+
"[@media(hover:hover)]:enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
|
|
45
|
+
"[@media(hover:hover)]:enabled:hover:[&[data-state=unchecked]:not([aria-invalid=true])]:border-foreground",
|
|
46
|
+
"focus-visible:!ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
47
|
+
"disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
33
48
|
className,
|
|
34
49
|
)}
|
|
35
50
|
{...props}
|
|
36
51
|
>
|
|
37
52
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
|
38
|
-
<Circle className="size-2.5 fill-
|
|
53
|
+
<Circle className="size-2.5 fill-current" />
|
|
39
54
|
</RadioGroupPrimitive.Indicator>
|
|
40
55
|
</RadioGroupPrimitive.Item>
|
|
41
56
|
));
|
|
@@ -13,6 +13,16 @@ import { cn } from "../lib/utils";
|
|
|
13
13
|
* is fixed, not exposed — a segmented control that could go fully
|
|
14
14
|
* unselected or multi-select isn't the pattern. Gap-fill addition (not in
|
|
15
15
|
* the original Figma source).
|
|
16
|
+
*
|
|
17
|
+
* Surfaces follow the surface model (TOKENS.md): the track is an inset well, `--surface-grouped`, and the
|
|
18
|
+
* chosen segment is a small raised surface on it — `--card`, the Card's half-strength `--border` edge and
|
|
19
|
+
* `sm` depth. The track used to be `--muted` with a `--background` segment, which in dark mode put the
|
|
20
|
+
* chosen segment BELOW its track (`--background` is the darkest surface), so it read as a hole.
|
|
21
|
+
*
|
|
22
|
+
* States (TOKENS.md, "Selection controls"): an unchosen segment answers the pointer with the same
|
|
23
|
+
* `--foreground` state layer the other selection controls use (8% hover, 14% pressed) and `--foreground`
|
|
24
|
+
* text; chosen is surface + edge + depth + `--foreground` text, so it is never colour alone; focus is the
|
|
25
|
+
* ring; disabled is inert.
|
|
16
26
|
*/
|
|
17
27
|
export type SegmentedControlProps = Omit<ToggleGroupPrimitive.ToggleGroupSingleProps, "type">;
|
|
18
28
|
|
|
@@ -23,7 +33,7 @@ const SegmentedControl = React.forwardRef<
|
|
|
23
33
|
<ToggleGroupPrimitive.Root
|
|
24
34
|
ref={ref}
|
|
25
35
|
type="single"
|
|
26
|
-
className={cn("inline-flex items-center rounded-lg bg-
|
|
36
|
+
className={cn("inline-flex items-center rounded-lg bg-surface-grouped p-1 font-sans", className)}
|
|
27
37
|
{...props}
|
|
28
38
|
/>
|
|
29
39
|
));
|
|
@@ -36,10 +46,16 @@ const SegmentedControlItem = React.forwardRef<
|
|
|
36
46
|
<ToggleGroupPrimitive.Item
|
|
37
47
|
ref={ref}
|
|
38
48
|
className={cn(
|
|
39
|
-
"inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-label-md font-medium text-muted-foreground
|
|
40
|
-
"
|
|
49
|
+
"inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-label-md font-medium text-muted-foreground",
|
|
50
|
+
"transition-[color,background-color,box-shadow] duration-fast ease-standard",
|
|
51
|
+
"[@media(hover:hover)]:enabled:hover:data-[state=off]:bg-foreground/[0.08] [@media(hover:hover)]:enabled:hover:data-[state=off]:text-foreground",
|
|
52
|
+
// Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
|
|
53
|
+
// emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
|
|
54
|
+
"enabled:active:data-[state=off]:bg-foreground/[0.14] enabled:active:data-[state=off]:text-foreground",
|
|
55
|
+
"[@media(hover:hover)]:enabled:active:data-[state=off]:bg-foreground/[0.14] [@media(hover:hover)]:enabled:active:data-[state=off]:text-foreground",
|
|
56
|
+
"data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:[box-shadow:inset_0_0_0_1px_hsl(var(--border)/0.5),var(--shadow-sm)]",
|
|
57
|
+
"outline-none focus-visible:!ring-2 focus-visible:ring-ring",
|
|
41
58
|
"disabled:pointer-events-none disabled:opacity-disabled",
|
|
42
|
-
"data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm",
|
|
43
59
|
className,
|
|
44
60
|
)}
|
|
45
61
|
{...props}
|
|
@@ -4,12 +4,16 @@ import * as React from "react";
|
|
|
4
4
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
5
5
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
|
+
import { withDirectionalPortal } from "./direction-provider";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Select — reconciled 1:1 with the KinetixUI design source, node 54855:13882.
|
|
10
|
-
* The trigger mirrors Input: `rounded-sm`,
|
|
11
|
-
*
|
|
11
|
+
* The trigger mirrors Input: `rounded-sm`, `--spacing-3` pad, Body Medium text, a soft `--shadow-focus`
|
|
12
|
+
* glow, `--destructive` on aria-invalid — and Input's text-entry state contract (TOKENS.md, "Text entry and
|
|
13
|
+
* navigation"): an 80% `--muted-foreground` edge at rest, full strength under a hovering pointer, and hover
|
|
14
|
+
* never over focus, an open list, invalid or disabled. Select has no read-only state.
|
|
12
15
|
*/
|
|
16
|
+
const SelectPortal = withDirectionalPortal(SelectPrimitive.Portal);
|
|
13
17
|
const Select = SelectPrimitive.Root;
|
|
14
18
|
const SelectGroup = SelectPrimitive.Group;
|
|
15
19
|
const SelectValue = SelectPrimitive.Value;
|
|
@@ -21,10 +25,23 @@ const SelectTrigger = React.forwardRef<
|
|
|
21
25
|
<SelectPrimitive.Trigger
|
|
22
26
|
ref={ref}
|
|
23
27
|
className={cn(
|
|
24
|
-
"flex min-h-[44px] w-full items-center justify-between rounded-sm border border-
|
|
28
|
+
"flex min-h-[44px] w-full items-center justify-between rounded-sm border border-muted-foreground/80 bg-background px-3 py-3",
|
|
25
29
|
"font-sans text-body-md text-foreground",
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
// The value wraps rather than being clamped to one line. `line-clamp-1` was invisible while the
|
|
31
|
+
// type scale was px — the value always fitted — but once text follows the reader's setting a
|
|
32
|
+
// two-line value is cut off at 200% with no ellipsis (measured: span needs 80px, gets 40px, and
|
|
33
|
+
// computed `text-overflow` is `clip`, not `ellipsis`), so "Select a fruit" rendered as "Select a".
|
|
34
|
+
// The trigger is already `min-h`, so letting it grow is what it was built for, and it matches how
|
|
35
|
+
// Input and Textarea absorb their own text. WCAG 1.4.4 asks for no loss of content at 200%.
|
|
36
|
+
//
|
|
37
|
+
// `min-w-0` and `overflow-wrap: anywhere` are what make that safe. A flex child keeps its
|
|
38
|
+
// min-content width by default, so a value with no break opportunity — an identifier, a URL with
|
|
39
|
+
// no separators — would simply not shrink: measured at 282px inside a 220px trigger, overflowing
|
|
40
|
+
// it and pushing the chevron outside. `line-clamp-1` had been hiding that by clipping. Breaking
|
|
41
|
+
// the token is the behaviour that keeps both the value and the chevron inside the control.
|
|
42
|
+
"data-[placeholder]:text-muted-foreground [&>span]:min-w-0 [&>span]:[overflow-wrap:anywhere]",
|
|
43
|
+
"outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
|
|
44
|
+
"[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([data-state=open])]:border-muted-foreground",
|
|
28
45
|
"focus:border-action focus:shadow-focus",
|
|
29
46
|
"data-[state=open]:border-action",
|
|
30
47
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:shadow-focus-destructive",
|
|
@@ -47,13 +64,13 @@ const SelectContent = React.forwardRef<
|
|
|
47
64
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
48
65
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
49
66
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
50
|
-
<
|
|
67
|
+
<SelectPortal>
|
|
51
68
|
<SelectPrimitive.Content
|
|
52
69
|
ref={ref}
|
|
53
70
|
position={position}
|
|
54
71
|
className={cn(
|
|
55
72
|
"relative z-overlay max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md font-sans",
|
|
56
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
73
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
|
|
57
74
|
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
|
|
58
75
|
className,
|
|
59
76
|
)}
|
|
@@ -71,7 +88,7 @@ const SelectContent = React.forwardRef<
|
|
|
71
88
|
<ChevronDown className="size-4" />
|
|
72
89
|
</SelectPrimitive.ScrollDownButton>
|
|
73
90
|
</SelectPrimitive.Content>
|
|
74
|
-
</
|
|
91
|
+
</SelectPortal>
|
|
75
92
|
));
|
|
76
93
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
|
77
94
|
|
package/src/components/sheet.tsx
CHANGED
|
@@ -5,11 +5,12 @@ import * as SheetPrimitive from "@radix-ui/react-dialog";
|
|
|
5
5
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
6
|
import { X } from "lucide-react";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
|
+
import { withDirectionalPortal } from "./direction-provider";
|
|
8
9
|
|
|
9
10
|
const Sheet = SheetPrimitive.Root;
|
|
10
11
|
const SheetTrigger = SheetPrimitive.Trigger;
|
|
11
12
|
const SheetClose = SheetPrimitive.Close;
|
|
12
|
-
const SheetPortal = SheetPrimitive.Portal;
|
|
13
|
+
const SheetPortal = withDirectionalPortal(SheetPrimitive.Portal);
|
|
13
14
|
|
|
14
15
|
const SheetOverlay = React.forwardRef<
|
|
15
16
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
|
@@ -18,7 +19,7 @@ const SheetOverlay = React.forwardRef<
|
|
|
18
19
|
<SheetPrimitive.Overlay
|
|
19
20
|
ref={ref}
|
|
20
21
|
className={cn(
|
|
21
|
-
"fixed inset-0 z-overlay bg-
|
|
22
|
+
"fixed inset-0 z-overlay bg-scrim backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
|
|
22
23
|
className,
|
|
23
24
|
)}
|
|
24
25
|
{...props}
|
|
@@ -73,7 +74,7 @@ const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
|
|
73
74
|
SheetHeader.displayName = "SheetHeader";
|
|
74
75
|
|
|
75
76
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
76
|
-
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end
|
|
77
|
+
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
|
77
78
|
);
|
|
78
79
|
SheetFooter.displayName = "SheetFooter";
|
|
79
80
|
|
|
@@ -9,7 +9,7 @@ import { cn } from "../lib/utils";
|
|
|
9
9
|
* dependency; `size` sets the diameter, `variant` swaps the indicator
|
|
10
10
|
* colour for on-color surfaces.
|
|
11
11
|
*/
|
|
12
|
-
const spinnerVariants = cva("inline-block animate-spin motion-reduce:animate-
|
|
12
|
+
const spinnerVariants = cva("inline-block animate-spin motion-reduce:animate-none rounded-full border-2 border-current border-t-transparent forced-colors:border-t-[Canvas]", {
|
|
13
13
|
variants: {
|
|
14
14
|
size: {
|
|
15
15
|
sm: "size-4",
|
|
@@ -5,9 +5,14 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
|
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Switch —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Switch — from the KinetixUI design source, node 54855:13984: 48×24 pill track, off = `--tertiary`, on =
|
|
9
|
+
* `--action`, 20px `--background` thumb that travels 24px, focus = 2px `--ring` offset.
|
|
10
|
+
*
|
|
11
|
+
* It follows the selection-control state contract (TOKENS.md, "Selection controls"): the same 8% / 14%
|
|
12
|
+
* `--foreground` state layer as Checkbox and Radio on hover and press, the on track steps to `--action`/90
|
|
13
|
+
* under the pointer, the thumb's position is the shape cue for on/off, the focus ring cannot be covered by
|
|
14
|
+
* any other layer, and a disabled switch does not answer the pointer. The off track stays `--tertiary`
|
|
15
|
+
* (2.6:1, a tracked SC 1.4.11 exception in check-contrast.mjs): the thumb and `aria-checked` carry it.
|
|
11
16
|
*/
|
|
12
17
|
const Switch = React.forwardRef<
|
|
13
18
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
|
@@ -16,18 +21,36 @@ const Switch = React.forwardRef<
|
|
|
16
21
|
<SwitchPrimitive.Root
|
|
17
22
|
ref={ref}
|
|
18
23
|
className={cn(
|
|
19
|
-
"peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent
|
|
20
|
-
"
|
|
21
|
-
"disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
24
|
+
"peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
|
|
25
|
+
"transition-[background-color,box-shadow] duration-instant ease-standard",
|
|
22
26
|
"data-[state=checked]:bg-action data-[state=unchecked]:bg-tertiary",
|
|
27
|
+
"[@media(hover:hover)]:enabled:hover:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.08)]",
|
|
28
|
+
// Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
|
|
29
|
+
// emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
|
|
30
|
+
"enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
|
|
31
|
+
"[@media(hover:hover)]:enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
|
|
32
|
+
"[@media(hover:hover)]:enabled:hover:data-[state=checked]:bg-action/90",
|
|
33
|
+
"focus-visible:outline-none focus-visible:!ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
34
|
+
"disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
23
35
|
className,
|
|
24
36
|
)}
|
|
25
37
|
{...props}
|
|
26
38
|
>
|
|
27
39
|
<SwitchPrimitive.Thumb
|
|
28
40
|
className={cn(
|
|
29
|
-
"pointer-events-none block size-5 rounded-full bg-background shadow-lg ring-0
|
|
30
|
-
|
|
41
|
+
"pointer-events-none relative block size-5 rounded-full bg-background shadow-lg ring-0",
|
|
42
|
+
// The thumb travels along the INLINE axis of the switch's own direction: `inset-inline-start` is
|
|
43
|
+
// logical, so the browser resolves start against the direction the switch is laid out in — the
|
|
44
|
+
// nearest `dir`, in an LTR section of an RTL page as much as anywhere else. It used to be a physical
|
|
45
|
+
// `translate-x` flipped by `rtl:`, and Tailwind 3's `rtl:` is `[dir=rtl] *` — ANY rtl ancestor — so
|
|
46
|
+
// a switch in an LTR section of an RTL page was flipped too and its thumb landed 22px outside the
|
|
47
|
+
// track, over its own focus ring (check:selection-visual, "direction"). `:dir()` would have been the
|
|
48
|
+
// selector answer, but Vite 8's Lightning CSS lowers `:dir(ltr)` for its default targets to a
|
|
49
|
+
// `:not(:lang(ar, he, …))` guess about the page's LANGUAGE, which broke every RTL switch in the built
|
|
50
|
+
// Storybook — a consumer's toolchain can do the same. A logical property needs no selector at all.
|
|
51
|
+
// The offset is relative, so nothing around the switch reflows while it moves.
|
|
52
|
+
"transition-[inset-inline-start] duration-instant ease-standard",
|
|
53
|
+
"start-0 data-[state=checked]:start-6",
|
|
31
54
|
)}
|
|
32
55
|
/>
|
|
33
56
|
</SwitchPrimitive.Root>
|