@sixthshift/design-system 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Badge/Badge.d.ts +10 -2
- package/dist/components/Badge/Badge.d.ts.map +1 -1
- package/dist/components/Badge/Badge.js +6 -4
- package/dist/components/Badge/Badge.js.map +1 -1
- package/dist/components/Button/Button.d.ts +25 -2
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/Button.js +13 -4
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/DataTable/DataTable.d.ts +89 -0
- package/dist/components/DataTable/DataTable.d.ts.map +1 -0
- package/dist/components/DataTable/DataTable.js +107 -0
- package/dist/components/DataTable/DataTable.js.map +1 -0
- package/dist/components/DataTable/index.d.ts +2 -0
- package/dist/components/DataTable/index.d.ts.map +1 -0
- package/dist/components/DataTable/index.js +2 -0
- package/dist/components/DataTable/index.js.map +1 -0
- package/dist/components/DatePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/DatePicker/DatePicker.js +2 -1
- package/dist/components/DatePicker/DatePicker.js.map +1 -1
- package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/components/DateTimePicker/DateTimePicker.js +2 -1
- package/dist/components/DateTimePicker/DateTimePicker.js.map +1 -1
- package/dist/components/DateTimeRangePicker/DateTimeRangePicker.d.ts.map +1 -1
- package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js +2 -1
- package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js.map +1 -1
- package/dist/components/FileDropzone/FileDropzone.d.ts +87 -0
- package/dist/components/FileDropzone/FileDropzone.d.ts.map +1 -0
- package/dist/components/FileDropzone/FileDropzone.js +146 -0
- package/dist/components/FileDropzone/FileDropzone.js.map +1 -0
- package/dist/components/FileDropzone/index.d.ts +2 -0
- package/dist/components/FileDropzone/index.d.ts.map +1 -0
- package/dist/components/FileDropzone/index.js +2 -0
- package/dist/components/FileDropzone/index.js.map +1 -0
- package/dist/components/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/components/HoverCard/HoverCard.js +3 -0
- package/dist/components/HoverCard/HoverCard.js.map +1 -1
- package/dist/components/HoverCard/components/HoverCardContent.d.ts +16 -2
- package/dist/components/HoverCard/components/HoverCardContent.d.ts.map +1 -1
- package/dist/components/HoverCard/components/HoverCardContent.js +3 -3
- package/dist/components/HoverCard/components/HoverCardContent.js.map +1 -1
- package/dist/components/HoverCard/components/HoverCardContext.d.ts +2 -0
- package/dist/components/HoverCard/components/HoverCardContext.d.ts.map +1 -1
- package/dist/components/HoverCard/components/HoverCardContext.js.map +1 -1
- package/dist/components/ImageGallery/ImageGallery.d.ts +93 -0
- package/dist/components/ImageGallery/ImageGallery.d.ts.map +1 -0
- package/dist/components/ImageGallery/ImageGallery.js +121 -0
- package/dist/components/ImageGallery/ImageGallery.js.map +1 -0
- package/dist/components/ImageGallery/index.d.ts +2 -0
- package/dist/components/ImageGallery/index.d.ts.map +1 -0
- package/dist/components/ImageGallery/index.js +2 -0
- package/dist/components/ImageGallery/index.js.map +1 -0
- package/dist/components/Message/Message.d.ts +4 -0
- package/dist/components/Message/Message.d.ts.map +1 -1
- package/dist/components/Message/Message.js +2 -2
- package/dist/components/Message/Message.js.map +1 -1
- package/dist/components/Modal/Modal.d.ts +12 -0
- package/dist/components/Modal/Modal.d.ts.map +1 -1
- package/dist/components/Modal/Modal.js +5 -2
- package/dist/components/Modal/Modal.js.map +1 -1
- package/dist/components/NumberStepper/NumberStepper.d.ts +78 -0
- package/dist/components/NumberStepper/NumberStepper.d.ts.map +1 -0
- package/dist/components/NumberStepper/NumberStepper.js +129 -0
- package/dist/components/NumberStepper/NumberStepper.js.map +1 -0
- package/dist/components/NumberStepper/index.d.ts +2 -0
- package/dist/components/NumberStepper/index.d.ts.map +1 -0
- package/dist/components/NumberStepper/index.js +2 -0
- package/dist/components/NumberStepper/index.js.map +1 -0
- package/dist/components/Popover/Popover.d.ts +3 -1
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.js +3 -0
- package/dist/components/Popover/Popover.js.map +1 -1
- package/dist/components/Popover/components/PopoverBody.d.ts +16 -2
- package/dist/components/Popover/components/PopoverBody.d.ts.map +1 -1
- package/dist/components/Popover/components/PopoverBody.js +3 -3
- package/dist/components/Popover/components/PopoverBody.js.map +1 -1
- package/dist/components/Popover/components/PopoverContext.d.ts +2 -0
- package/dist/components/Popover/components/PopoverContext.d.ts.map +1 -1
- package/dist/components/Popover/components/PopoverContext.js.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.js +3 -2
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/Select/SelectDropdown.d.ts +3 -1
- package/dist/components/Select/SelectDropdown.d.ts.map +1 -1
- package/dist/components/Select/SelectDropdown.js +2 -2
- package/dist/components/Select/SelectDropdown.js.map +1 -1
- package/dist/components/Sheet/Sheet.d.ts +12 -0
- package/dist/components/Sheet/Sheet.d.ts.map +1 -1
- package/dist/components/Sheet/Sheet.js +4 -2
- package/dist/components/Sheet/Sheet.js.map +1 -1
- package/dist/components/Steps/Steps.d.ts +65 -0
- package/dist/components/Steps/Steps.d.ts.map +1 -0
- package/dist/components/Steps/Steps.js +62 -0
- package/dist/components/Steps/Steps.js.map +1 -0
- package/dist/components/Steps/index.d.ts +2 -0
- package/dist/components/Steps/index.d.ts.map +1 -0
- package/dist/components/Steps/index.js +2 -0
- package/dist/components/Steps/index.js.map +1 -0
- package/dist/components/TimePicker/TimePicker.d.ts.map +1 -1
- package/dist/components/TimePicker/TimePicker.js +2 -1
- package/dist/components/TimePicker/TimePicker.js.map +1 -1
- package/dist/components/Toast/Toast.d.ts.map +1 -1
- package/dist/components/Toast/Toast.js +2 -2
- package/dist/components/Toast/Toast.js.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts +4 -0
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.js +2 -2
- package/dist/components/Toggle/Toggle.js.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts +1 -0
- package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupMultiple.js +2 -2
- package/dist/components/ToggleGroup/ToggleGroupMultiple.js.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts +1 -0
- package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroupSingle.js +2 -2
- package/dist/components/ToggleGroup/ToggleGroupSingle.js.map +1 -1
- package/dist/components/ToggleGroup/toggleGroup.types.d.ts +6 -0
- package/dist/components/ToggleGroup/toggleGroup.types.d.ts.map +1 -1
- package/dist/components/Tooltip/Tooltip.d.ts +3 -1
- package/dist/components/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/components/Tooltip/Tooltip.js +3 -0
- package/dist/components/Tooltip/Tooltip.js.map +1 -1
- package/dist/components/Tooltip/components/TooltipBody.d.ts +16 -2
- package/dist/components/Tooltip/components/TooltipBody.d.ts.map +1 -1
- package/dist/components/Tooltip/components/TooltipBody.js +3 -3
- package/dist/components/Tooltip/components/TooltipBody.js.map +1 -1
- package/dist/components/Tooltip/components/TooltipContext.d.ts +2 -0
- package/dist/components/Tooltip/components/TooltipContext.d.ts.map +1 -1
- package/dist/components/Tooltip/components/TooltipContext.js.map +1 -1
- package/dist/internal/brandScope.d.ts +25 -0
- package/dist/internal/brandScope.d.ts.map +1 -0
- package/dist/internal/brandScope.js +37 -0
- package/dist/internal/brandScope.js.map +1 -0
- package/package.json +21 -1
- package/src/components/Badge/Badge.tsx +10 -4
- package/src/components/Button/Button.tsx +24 -4
- package/src/components/DataTable/DataTable.tsx +261 -0
- package/src/components/DataTable/data-table.recipe.css +28 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.tsx +3 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +3 -0
- package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +3 -0
- package/src/components/FileDropzone/FileDropzone.tsx +239 -0
- package/src/components/FileDropzone/file-dropzone.recipe.css +38 -0
- package/src/components/FileDropzone/index.ts +1 -0
- package/src/components/HoverCard/HoverCard.tsx +3 -0
- package/src/components/HoverCard/components/HoverCardContent.tsx +11 -3
- package/src/components/HoverCard/components/HoverCardContext.tsx +2 -0
- package/src/components/ImageGallery/ImageGallery.tsx +285 -0
- package/src/components/ImageGallery/image-gallery.recipe.css +50 -0
- package/src/components/ImageGallery/index.ts +7 -0
- package/src/components/Message/Message.tsx +26 -22
- package/src/components/Modal/Modal.tsx +11 -0
- package/src/components/NumberStepper/NumberStepper.tsx +227 -0
- package/src/components/NumberStepper/index.ts +1 -0
- package/src/components/NumberStepper/number-stepper.recipe.css +31 -0
- package/src/components/Popover/Popover.tsx +3 -0
- package/src/components/Popover/components/PopoverBody.tsx +11 -3
- package/src/components/Popover/components/PopoverContext.tsx +2 -0
- package/src/components/Select/Select.tsx +3 -1
- package/src/components/Select/SelectDropdown.tsx +4 -0
- package/src/components/Sheet/Sheet.tsx +10 -0
- package/src/components/Steps/Steps.tsx +149 -0
- package/src/components/Steps/index.ts +1 -0
- package/src/components/Steps/steps.recipe.css +51 -0
- package/src/components/TimePicker/TimePicker.tsx +3 -0
- package/src/components/Toast/Toast.tsx +5 -1
- package/src/components/Toggle/Toggle.tsx +17 -2
- package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +2 -1
- package/src/components/ToggleGroup/ToggleGroupSingle.tsx +2 -0
- package/src/components/ToggleGroup/toggleGroup.types.ts +6 -0
- package/src/components/Tooltip/Tooltip.tsx +3 -0
- package/src/components/Tooltip/components/TooltipBody.tsx +11 -3
- package/src/components/Tooltip/components/TooltipContext.tsx +2 -0
- package/src/internal/brandScope.ts +39 -0
- package/src/theming/tailwind.css +5 -0
|
@@ -48,6 +48,8 @@ export type MessageIntent = Loose<MessageIntentName>;
|
|
|
48
48
|
export type MessageProps = React.HTMLAttributes<HTMLDivElement> &
|
|
49
49
|
VariantProps<typeof messageVariants> & {
|
|
50
50
|
intent?: MessageIntent | undefined;
|
|
51
|
+
/** Name of a brand the theme defines, rendered as `data-brand`. Omitted, the message follows the nearest `data-brand` ancestor. */
|
|
52
|
+
brand?: string | undefined;
|
|
51
53
|
/** Simple API: Optional title displayed above children */
|
|
52
54
|
title?: React.ReactNode;
|
|
53
55
|
/** Simple API: Optional icon displayed on the left */
|
|
@@ -99,27 +101,29 @@ function intentRole(intent: MessageIntent): "status" | "alert" {
|
|
|
99
101
|
* simple API can't express. Message detects which one is in use by
|
|
100
102
|
* inspecting its children, so the two aren't meant to be mixed.
|
|
101
103
|
*/
|
|
102
|
-
export const Message = React.forwardRef<HTMLDivElement, MessageProps>(
|
|
103
|
-
|
|
104
|
-
(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
export const Message = React.forwardRef<HTMLDivElement, MessageProps>(
|
|
105
|
+
({ className, intent = "neutral", brand, size, title, icon, children, ...props }, ref) => {
|
|
106
|
+
const hasCompoundChildren = React.Children.toArray(children).some(
|
|
107
|
+
(child) =>
|
|
108
|
+
React.isValidElement(child) &&
|
|
109
|
+
(child.type === MessageIcon || child.type === MessageBody || child.type === MessageTitle || child.type === MessageDescription)
|
|
110
|
+
);
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
112
|
+
return (
|
|
113
|
+
<div ref={ref} role={intentRole(intent)} data-intent={intent} data-brand={brand} className={cn(messageVariants({ size, className }))} {...props}>
|
|
114
|
+
{hasCompoundChildren ? (
|
|
115
|
+
children
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
{icon && <MessageIcon>{icon}</MessageIcon>}
|
|
119
|
+
<MessageBody>
|
|
120
|
+
{title && <MessageTitle>{title}</MessageTitle>}
|
|
121
|
+
<MessageDescription>{children}</MessageDescription>
|
|
122
|
+
</MessageBody>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
);
|
|
125
129
|
Message.displayName = "Message";
|
|
@@ -4,6 +4,7 @@ import { FloatingFocusManager, FloatingOverlay, FloatingPortal, useDismiss, useF
|
|
|
4
4
|
import { useMergedFloatingRef, usePresence } from "@sixthshift/design-system/hooks";
|
|
5
5
|
import { cn } from "@sixthshift/design-system/utils";
|
|
6
6
|
import { forwardRef, type HTMLAttributes, type ReactNode, useCallback, useContext, useEffect, useId, useMemo, useState } from "react";
|
|
7
|
+
import { useOpenerBrand } from "../../internal/brandScope";
|
|
7
8
|
import { hasOpenEscapeLayer, useEscapeDialog } from "../../internal/escapeLayers";
|
|
8
9
|
import { ModalBody, ModalContext, ModalFooter, ModalHeader } from "./components";
|
|
9
10
|
|
|
@@ -35,6 +36,12 @@ export type ModalProps = Pick<HTMLAttributes<HTMLDivElement>, "className" | "sty
|
|
|
35
36
|
closable?: boolean;
|
|
36
37
|
/** Vertical alignment on desktop. "center" (default) or "top" (anchored near top, content grows down) */
|
|
37
38
|
align?: "center" | "top";
|
|
39
|
+
/**
|
|
40
|
+
* Name of a brand the theme defines, rendered as `data-brand` on the dialog.
|
|
41
|
+
* Omitted, the modal takes the brand scope of the control that opened it
|
|
42
|
+
* (whatever held focus), since the portal leaves the trigger's scope behind.
|
|
43
|
+
*/
|
|
44
|
+
brand?: string | undefined;
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
// =============================================================================
|
|
@@ -63,6 +70,7 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(
|
|
|
63
70
|
dismissable = true,
|
|
64
71
|
closable,
|
|
65
72
|
align = "center",
|
|
73
|
+
brand,
|
|
66
74
|
"aria-label": ariaLabel,
|
|
67
75
|
"aria-labelledby": ariaLabelledBy,
|
|
68
76
|
"aria-describedby": ariaDescribedBy,
|
|
@@ -81,6 +89,8 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(
|
|
|
81
89
|
}, [onOpenChange, contextValue]);
|
|
82
90
|
|
|
83
91
|
const { ref: presenceRef, state, isMounted, show, hide } = usePresence();
|
|
92
|
+
// Mount-driven, so the modal is open for as long as it exists.
|
|
93
|
+
const openerBrand = useOpenerBrand(true);
|
|
84
94
|
|
|
85
95
|
useEffect(() => {
|
|
86
96
|
show();
|
|
@@ -163,6 +173,7 @@ const ModalRoot = forwardRef<HTMLDivElement, ModalProps>(
|
|
|
163
173
|
aria-describedby={ariaDescribedBy}
|
|
164
174
|
tabIndex={-1}
|
|
165
175
|
data-state={state}
|
|
176
|
+
data-brand={brand ?? openerBrand}
|
|
166
177
|
className={cn(
|
|
167
178
|
"modal fixed flex flex-col overflow-hidden outline-hidden",
|
|
168
179
|
"border-(color:--modal-border) rounded-xl border bg-(--modal-bg)",
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useControllableState } from "@sixthshift/design-system/hooks";
|
|
4
|
+
import { cn } from "@sixthshift/design-system/utils";
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
|
+
import { Minus, Plus } from "lucide-react";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Geometry only. Every colour reads a `--number-stepper-*` component token
|
|
11
|
+
* whose value is decided by src/components/NumberStepper/number-stepper.recipe.css.
|
|
12
|
+
*
|
|
13
|
+
* `size` tracks Input's and Button's heights (`sm` 32px, `md` 36px, `lg`
|
|
14
|
+
* 40px) so a stepper sits on the same baseline as the fields beside it.
|
|
15
|
+
*/
|
|
16
|
+
const numberStepperVariants = cva(
|
|
17
|
+
// One literal, deliberately — see Button.tsx for why a `+` concatenation is a trap.
|
|
18
|
+
"number-stepper border-(color:--number-stepper-border) focus-within:ring-(color:--number-stepper-ring) inline-flex w-fit items-stretch overflow-hidden rounded-md border bg-(--number-stepper-bg) text-(--number-stepper-fg) shadow-xs transition-colors focus-within:ring-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
|
|
19
|
+
{
|
|
20
|
+
variants: {
|
|
21
|
+
size: {
|
|
22
|
+
sm: "h-8 text-xs [&_svg]:size-3.5",
|
|
23
|
+
md: "h-9 text-sm [&_svg]:size-4",
|
|
24
|
+
lg: "h-10 text-sm [&_svg]:size-4",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
size: "md",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** Button width per size — square against the field's height. */
|
|
34
|
+
const buttonGeometry: Record<string, string> = {
|
|
35
|
+
sm: "w-8",
|
|
36
|
+
md: "w-9",
|
|
37
|
+
lg: "w-10",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Input width per size — room for three digits before it grows. */
|
|
41
|
+
const inputGeometry: Record<string, string> = {
|
|
42
|
+
sm: "w-10",
|
|
43
|
+
md: "w-12",
|
|
44
|
+
lg: "w-14",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type NumberStepperSize = NonNullable<VariantProps<typeof numberStepperVariants>["size"]>;
|
|
48
|
+
|
|
49
|
+
export type NumberStepperProps = Omit<
|
|
50
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
51
|
+
"value" | "defaultValue" | "onChange" | "size" | "min" | "max" | "step" | "type"
|
|
52
|
+
> &
|
|
53
|
+
VariantProps<typeof numberStepperVariants> & {
|
|
54
|
+
/** Controlled value. */
|
|
55
|
+
value?: number;
|
|
56
|
+
/** Initial value in uncontrolled mode. Defaults to `min`, or `0` without one. */
|
|
57
|
+
defaultValue?: number;
|
|
58
|
+
/** Called with the new, already-clamped value. */
|
|
59
|
+
onValueChange?: (value: number) => void;
|
|
60
|
+
/** Lowest reachable value. Defaults to `0` — quantities are the common case. */
|
|
61
|
+
min?: number;
|
|
62
|
+
/** Highest reachable value. Unbounded when omitted. */
|
|
63
|
+
max?: number;
|
|
64
|
+
/** Increment for the buttons and arrow keys. `PageUp`/`PageDown` move ten of them. */
|
|
65
|
+
step?: number;
|
|
66
|
+
/** Accessible name for the decrement button. */
|
|
67
|
+
decrementLabel?: string;
|
|
68
|
+
/** Accessible name for the increment button. */
|
|
69
|
+
incrementLabel?: string;
|
|
70
|
+
/** Extra classes for the inner `<input>`; `className` styles the surrounding group. */
|
|
71
|
+
inputClassName?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function clamp(value: number, min: number, max: number | undefined): number {
|
|
75
|
+
const floored = Math.max(min, value);
|
|
76
|
+
return max === undefined ? floored : Math.min(max, floored);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A whole-number field with decrement and increment buttons — a cart
|
|
81
|
+
* quantity, a guest count, a per-day capacity.
|
|
82
|
+
*
|
|
83
|
+
* Implements the WAI-ARIA spinbutton pattern on the `<input>` itself:
|
|
84
|
+
* `role="spinbutton"` with `aria-valuenow`/`min`/`max`, `ArrowUp`/`ArrowDown`
|
|
85
|
+
* step, `PageUp`/`PageDown` step by ten, and `Home`/`End` jump to the bounds
|
|
86
|
+
* that exist. The two buttons are pointer affordances and stay out of the tab
|
|
87
|
+
* order, so the field is one tab stop — the same reason RadioButtonGroup is.
|
|
88
|
+
*
|
|
89
|
+
* Typing is free-form while focused and committed on blur or `Enter`: the
|
|
90
|
+
* draft is parsed, rounded to the nearest `step` from `min`, and clamped.
|
|
91
|
+
* Anything unparseable reverts to the last committed value rather than
|
|
92
|
+
* reporting `NaN`. Every value `onValueChange` receives is in range.
|
|
93
|
+
*
|
|
94
|
+
* Controlled or uncontrolled through the `value`/`defaultValue`/
|
|
95
|
+
* `onValueChange` triad. `name` submits the committed value with a form, since
|
|
96
|
+
* the input is a real `<input>`. The ref and every unlisted prop land on that
|
|
97
|
+
* input — so `FormField`'s `id`/`aria-describedby` wiring reaches the control
|
|
98
|
+
* — while `className` styles the surrounding group.
|
|
99
|
+
*/
|
|
100
|
+
export const NumberStepper = React.forwardRef<HTMLInputElement, NumberStepperProps>(
|
|
101
|
+
(
|
|
102
|
+
{
|
|
103
|
+
className,
|
|
104
|
+
inputClassName,
|
|
105
|
+
size = "md",
|
|
106
|
+
value: controlledValue,
|
|
107
|
+
defaultValue,
|
|
108
|
+
onValueChange,
|
|
109
|
+
min = 0,
|
|
110
|
+
max,
|
|
111
|
+
step = 1,
|
|
112
|
+
disabled = false,
|
|
113
|
+
readOnly = false,
|
|
114
|
+
decrementLabel = "Decrease",
|
|
115
|
+
incrementLabel = "Increase",
|
|
116
|
+
onKeyDown,
|
|
117
|
+
onBlur,
|
|
118
|
+
onFocus,
|
|
119
|
+
...props
|
|
120
|
+
},
|
|
121
|
+
ref
|
|
122
|
+
) => {
|
|
123
|
+
const [value, setValue] = useControllableState({
|
|
124
|
+
value: controlledValue,
|
|
125
|
+
defaultValue: clamp(defaultValue ?? min, min, max),
|
|
126
|
+
onChange: onValueChange,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// The text being typed, or `null` when the field shows the committed value.
|
|
130
|
+
const [draft, setDraft] = React.useState<string | null>(null);
|
|
131
|
+
|
|
132
|
+
const commit = (next: number) => {
|
|
133
|
+
const snapped = min + Math.round((next - min) / step) * step;
|
|
134
|
+
const clamped = clamp(snapped, min, max);
|
|
135
|
+
if (clamped !== value) setValue(clamped);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const commitDraft = () => {
|
|
139
|
+
if (draft === null) return;
|
|
140
|
+
const parsed = Number.parseFloat(draft);
|
|
141
|
+
setDraft(null);
|
|
142
|
+
if (Number.isFinite(parsed)) commit(parsed);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const interactive = !disabled && !readOnly;
|
|
146
|
+
const atMin = value <= min;
|
|
147
|
+
const atMax = max !== undefined && value >= max;
|
|
148
|
+
|
|
149
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
150
|
+
onKeyDown?.(event);
|
|
151
|
+
if (event.defaultPrevented || !interactive) return;
|
|
152
|
+
|
|
153
|
+
const moves: Record<string, (() => number) | undefined> = {
|
|
154
|
+
ArrowUp: () => value + step,
|
|
155
|
+
ArrowDown: () => value - step,
|
|
156
|
+
PageUp: () => value + step * 10,
|
|
157
|
+
PageDown: () => value - step * 10,
|
|
158
|
+
Home: () => min,
|
|
159
|
+
End: max === undefined ? undefined : () => max,
|
|
160
|
+
};
|
|
161
|
+
const move = moves[event.key];
|
|
162
|
+
|
|
163
|
+
if (event.key === "Enter") {
|
|
164
|
+
commitDraft();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!move) return;
|
|
168
|
+
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
setDraft(null);
|
|
171
|
+
commit(move());
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
175
|
+
commitDraft();
|
|
176
|
+
onBlur?.(event);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const stepBy = (direction: 1 | -1) => {
|
|
180
|
+
setDraft(null);
|
|
181
|
+
commit(value + step * direction);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const buttonClass = cn(
|
|
185
|
+
"number-stepper-button flex shrink-0 cursor-pointer items-center justify-center text-(--number-stepper-button-fg) transition-colors enabled:active:bg-(--number-stepper-button-bg-pressed) enabled:hover:bg-(--number-stepper-button-bg-hovered) disabled:cursor-not-allowed disabled:text-(--number-stepper-button-fg-disabled)",
|
|
186
|
+
buttonGeometry[size ?? "md"]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
// biome-ignore lint/a11y/useSemanticElements: a <fieldset> brings a border, min-width and legend semantics this inline control does not want; role="group" only ties the buttons to the field
|
|
191
|
+
<div role="group" data-disabled={disabled} className={numberStepperVariants({ size, className })}>
|
|
192
|
+
<button type="button" tabIndex={-1} aria-label={decrementLabel} disabled={!interactive || atMin} onClick={() => stepBy(-1)} className={buttonClass}>
|
|
193
|
+
<Minus aria-hidden="true" />
|
|
194
|
+
</button>
|
|
195
|
+
<input
|
|
196
|
+
ref={ref}
|
|
197
|
+
type="text"
|
|
198
|
+
inputMode="numeric"
|
|
199
|
+
role="spinbutton"
|
|
200
|
+
autoComplete="off"
|
|
201
|
+
aria-valuenow={value}
|
|
202
|
+
aria-valuemin={min}
|
|
203
|
+
aria-valuemax={max}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
readOnly={readOnly}
|
|
206
|
+
value={draft ?? String(value)}
|
|
207
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
208
|
+
onKeyDown={handleKeyDown}
|
|
209
|
+
onBlur={handleBlur}
|
|
210
|
+
onFocus={onFocus}
|
|
211
|
+
className={cn(
|
|
212
|
+
"border-(color:--number-stepper-divider-border) min-w-0 border-x bg-transparent text-center text-[length:inherit] tabular-nums outline-hidden disabled:cursor-not-allowed",
|
|
213
|
+
inputGeometry[size ?? "md"],
|
|
214
|
+
inputClassName
|
|
215
|
+
)}
|
|
216
|
+
{...props}
|
|
217
|
+
/>
|
|
218
|
+
<button type="button" tabIndex={-1} aria-label={incrementLabel} disabled={!interactive || atMax} onClick={() => stepBy(1)} className={buttonClass}>
|
|
219
|
+
<Plus aria-hidden="true" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
NumberStepper.displayName = "NumberStepper";
|
|
226
|
+
|
|
227
|
+
export { numberStepperVariants };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NumberStepper, type NumberStepperProps, type NumberStepperSize, numberStepperVariants } from "./NumberStepper";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NumberStepper recipe — the component-token layer for NumberStepper.
|
|
3
|
+
*
|
|
4
|
+
* No variant/intent axis: a stepper is a form field, and like Input every
|
|
5
|
+
* colour is a fixed semantic token. The field surface deliberately mirrors
|
|
6
|
+
* input.css's values (bg-normal, border-normal, focus-ring) so a stepper next
|
|
7
|
+
* to a text input reads as the same kind of control — but through its own
|
|
8
|
+
* tokens, so a consumer can re-point one without the other.
|
|
9
|
+
*
|
|
10
|
+
* The grammar is `--number-stepper-[{part}-]{context}[-{state}]`. Parts are
|
|
11
|
+
* `button` (the two step buttons) and `divider` (the rules either side of the
|
|
12
|
+
* value). `--number-stepper-button-fg-disabled` is the colour a button takes
|
|
13
|
+
* at a bound; the whole group dims via opacity only when the field itself is
|
|
14
|
+
* disabled, the same way Input does.
|
|
15
|
+
*
|
|
16
|
+
* `@layer components` is load-bearing, not tidiness — see button.css.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
@layer components {
|
|
20
|
+
.number-stepper {
|
|
21
|
+
--number-stepper-bg: var(--bg-normal);
|
|
22
|
+
--number-stepper-fg: var(--fg-normal);
|
|
23
|
+
--number-stepper-border: var(--border-normal);
|
|
24
|
+
--number-stepper-ring: var(--focus-ring);
|
|
25
|
+
--number-stepper-divider-border: var(--border-normal);
|
|
26
|
+
--number-stepper-button-fg: var(--fg-normal);
|
|
27
|
+
--number-stepper-button-fg-disabled: var(--fg-normal-disabled);
|
|
28
|
+
--number-stepper-button-bg-hovered: var(--bg-normal-hovered);
|
|
29
|
+
--number-stepper-button-bg-pressed: var(--bg-normal-pressed);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -4,6 +4,7 @@ import { autoUpdate, flip, offset, type Placement, shift, useClick, useDismiss,
|
|
|
4
4
|
import { useControllableState } from "@sixthshift/design-system/hooks";
|
|
5
5
|
import type * as React from "react";
|
|
6
6
|
import { useId } from "react";
|
|
7
|
+
import { closestBrand } from "../../internal/brandScope";
|
|
7
8
|
import { useEscapeLayer } from "../../internal/escapeLayers";
|
|
8
9
|
import { PopoverBody } from "./components/PopoverBody";
|
|
9
10
|
import { PopoverClose } from "./components/PopoverClose";
|
|
@@ -66,6 +67,8 @@ const PopoverRoot = ({ open: controlledOpen, onOpenChange, defaultOpen = false,
|
|
|
66
67
|
setFloating: refs.setFloating,
|
|
67
68
|
},
|
|
68
69
|
floatingStyles,
|
|
70
|
+
// Read at render: `domReference` is state, so this updates once the trigger mounts.
|
|
71
|
+
inheritedBrand: closestBrand(context.elements.domReference),
|
|
69
72
|
getReferenceProps,
|
|
70
73
|
getFloatingProps,
|
|
71
74
|
contentId,
|
|
@@ -5,10 +5,17 @@ import { cn } from "@sixthshift/design-system/utils";
|
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
import { usePopoverContext } from "./PopoverContext";
|
|
7
7
|
|
|
8
|
-
export type PopoverBodyProps = React.HTMLAttributes<HTMLDivElement
|
|
8
|
+
export type PopoverBodyProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
9
|
+
/**
|
|
10
|
+
* Name of a brand the theme defines, rendered as `data-brand`. Omitted, the
|
|
11
|
+
* body copies the brand scope of its trigger, which the portal would
|
|
12
|
+
* otherwise leave behind.
|
|
13
|
+
*/
|
|
14
|
+
brand?: string | undefined;
|
|
15
|
+
};
|
|
9
16
|
|
|
10
|
-
export const PopoverBody = React.forwardRef<HTMLDivElement, PopoverBodyProps>(({ className, children, ...props }, forwardedRef) => {
|
|
11
|
-
const { open, refs, floatingStyles, getFloatingProps, contentId, triggerId } = usePopoverContext();
|
|
17
|
+
export const PopoverBody = React.forwardRef<HTMLDivElement, PopoverBodyProps>(({ className, brand, children, ...props }, forwardedRef) => {
|
|
18
|
+
const { open, refs, floatingStyles, inheritedBrand, getFloatingProps, contentId, triggerId } = usePopoverContext();
|
|
12
19
|
|
|
13
20
|
// `aria-labelledby` outranks `aria-label` in the name computation, so the
|
|
14
21
|
// trigger only names the dialog when the caller hasn't named it themselves.
|
|
@@ -28,6 +35,7 @@ export const PopoverBody = React.forwardRef<HTMLDivElement, PopoverBodyProps>(({
|
|
|
28
35
|
role="dialog"
|
|
29
36
|
aria-labelledby={hasOwnLabel ? undefined : triggerId}
|
|
30
37
|
style={floatingStyles}
|
|
38
|
+
data-brand={brand ?? inheritedBrand}
|
|
31
39
|
className={cn("z-popover rounded-lg border border-border-normal bg-bg-normal p-4 shadow-lg", className)}
|
|
32
40
|
{...getFloatingProps(props)}
|
|
33
41
|
>
|
|
@@ -10,6 +10,8 @@ export type PopoverContextValue = {
|
|
|
10
10
|
setFloating: (node: HTMLElement | null) => void;
|
|
11
11
|
};
|
|
12
12
|
floatingStyles: React.CSSProperties;
|
|
13
|
+
/** The trigger's brand scope, re-stamped on the portalled body. */
|
|
14
|
+
inheritedBrand: string | undefined;
|
|
13
15
|
getReferenceProps: (props?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
|
|
14
16
|
getFloatingProps: (props?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
|
|
15
17
|
contentId: string;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { autoUpdate, flip, offset, shift, size, useFloating } from "@floating-ui/react";
|
|
4
4
|
import { useControllableState } from "@sixthshift/design-system/hooks";
|
|
5
5
|
import { forwardRef, type HTMLAttributes, type ReactElement, type Ref, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { closestBrand } from "../../internal/brandScope";
|
|
6
7
|
import { useEscapeLayer } from "../../internal/escapeLayers";
|
|
7
8
|
import { SelectDropdown, selectOptionId } from "./SelectDropdown";
|
|
8
9
|
import { SelectTriggerButton, SelectTriggerSearch } from "./SelectTrigger";
|
|
@@ -117,7 +118,7 @@ const SelectRoot = forwardRef(function SelectRoot<T extends string = string>(pro
|
|
|
117
118
|
const showClearButton = clearable && hasValue && !disabled;
|
|
118
119
|
|
|
119
120
|
// Floating UI
|
|
120
|
-
const { refs, floatingStyles } = useFloating({
|
|
121
|
+
const { refs, elements, floatingStyles } = useFloating({
|
|
121
122
|
open,
|
|
122
123
|
onOpenChange: setOpen,
|
|
123
124
|
placement: "bottom-start",
|
|
@@ -342,6 +343,7 @@ const SelectRoot = forwardRef(function SelectRoot<T extends string = string>(pro
|
|
|
342
343
|
label={listboxLabel}
|
|
343
344
|
activeOptionId={activeOptionId}
|
|
344
345
|
floatingStyles={floatingStyles}
|
|
346
|
+
brand={closestBrand(elements.domReference)}
|
|
345
347
|
displayOptions={filteredOptions}
|
|
346
348
|
highlightedIndex={highlightedIndex}
|
|
347
349
|
selectedValues={selectedValues}
|
|
@@ -23,6 +23,8 @@ interface SelectDropdownProps<T extends string> {
|
|
|
23
23
|
/** The highlighted option's id, or undefined when nothing is highlighted. */
|
|
24
24
|
activeOptionId: string | undefined;
|
|
25
25
|
floatingStyles: React.CSSProperties;
|
|
26
|
+
/** The trigger's brand scope, re-stamped here because the portal leaves it behind. */
|
|
27
|
+
brand: string | undefined;
|
|
26
28
|
displayOptions: readonly { value: T; label: string }[];
|
|
27
29
|
highlightedIndex: number;
|
|
28
30
|
selectedValues: Set<T>;
|
|
@@ -41,6 +43,7 @@ export const SelectDropdown = <T extends string>({
|
|
|
41
43
|
label,
|
|
42
44
|
activeOptionId,
|
|
43
45
|
floatingStyles,
|
|
46
|
+
brand,
|
|
44
47
|
displayOptions,
|
|
45
48
|
highlightedIndex,
|
|
46
49
|
selectedValues,
|
|
@@ -68,6 +71,7 @@ export const SelectDropdown = <T extends string>({
|
|
|
68
71
|
tabIndex={-1}
|
|
69
72
|
onKeyDown={onKeyDown}
|
|
70
73
|
style={floatingStyles}
|
|
74
|
+
data-brand={brand}
|
|
71
75
|
className="select-dropdown border-(color:--select-dropdown-border) z-popover max-h-60 overflow-y-auto rounded-md border bg-(--select-dropdown-bg) shadow-lg"
|
|
72
76
|
>
|
|
73
77
|
{displayOptions.length === 0 ? (
|
|
@@ -4,6 +4,7 @@ import { FloatingFocusManager, FloatingPortal, useDismiss, useFloating, useInter
|
|
|
4
4
|
import { useMergedFloatingRef, usePresence } from "@sixthshift/design-system/hooks";
|
|
5
5
|
import { cn } from "@sixthshift/design-system/utils";
|
|
6
6
|
import { forwardRef, type HTMLAttributes, type ReactNode, useCallback, useEffect, useId, useMemo, useState } from "react";
|
|
7
|
+
import { useOpenerBrand } from "../../internal/brandScope";
|
|
7
8
|
import { hasOpenEscapeDialog, hasOpenEscapeLayer } from "../../internal/escapeLayers";
|
|
8
9
|
import { SheetBody, SheetContext, SheetFooter, SheetHeader } from "./components";
|
|
9
10
|
|
|
@@ -26,6 +27,12 @@ export type SheetProps = Pick<HTMLAttributes<HTMLDivElement>, "className" | "sty
|
|
|
26
27
|
dismissOnOutsidePress?: boolean;
|
|
27
28
|
/** When true, shows a close (X) button in the header */
|
|
28
29
|
closable?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Name of a brand the theme defines, rendered as `data-brand` on the sheet.
|
|
32
|
+
* Omitted, the sheet takes the brand scope of the control that opened it
|
|
33
|
+
* (whatever held focus), since the portal leaves the trigger's scope behind.
|
|
34
|
+
*/
|
|
35
|
+
brand?: string | undefined;
|
|
29
36
|
/** Children — use Sheet.Header, Sheet.Body, Sheet.Footer */
|
|
30
37
|
children: ReactNode;
|
|
31
38
|
};
|
|
@@ -54,6 +61,7 @@ const SheetRoot = forwardRef<HTMLDivElement, SheetProps>(
|
|
|
54
61
|
dismissable = true,
|
|
55
62
|
dismissOnOutsidePress = false,
|
|
56
63
|
closable,
|
|
64
|
+
brand,
|
|
57
65
|
className,
|
|
58
66
|
style,
|
|
59
67
|
children,
|
|
@@ -64,6 +72,7 @@ const SheetRoot = forwardRef<HTMLDivElement, SheetProps>(
|
|
|
64
72
|
ref
|
|
65
73
|
) => {
|
|
66
74
|
const { ref: presenceRef, state, isMounted, show, hide } = usePresence();
|
|
75
|
+
const openerBrand = useOpenerBrand(open);
|
|
67
76
|
|
|
68
77
|
// Drive presence from the controlled `open` prop.
|
|
69
78
|
// When `open` flips to false, we play the exit animation, then unmount.
|
|
@@ -141,6 +150,7 @@ const SheetRoot = forwardRef<HTMLDivElement, SheetProps>(
|
|
|
141
150
|
tabIndex={-1}
|
|
142
151
|
data-state={state}
|
|
143
152
|
data-side={side}
|
|
153
|
+
data-brand={brand ?? openerBrand}
|
|
144
154
|
className={cn(
|
|
145
155
|
"sheet fixed top-0 bottom-0 z-sheet flex flex-col overflow-hidden bg-(--sheet-bg) outline-hidden",
|
|
146
156
|
"border-(color:--sheet-border) shadow-lg",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@sixthshift/design-system/utils";
|
|
4
|
+
import { cva } from "class-variance-authority";
|
|
5
|
+
import { Check } from "lucide-react";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Geometry only. Every colour reads a `--steps-*` component token whose value
|
|
10
|
+
* is decided by src/components/Steps/steps.recipe.css, selected by the
|
|
11
|
+
* `data-state` each item renders (`complete`, `current`, `upcoming`).
|
|
12
|
+
*/
|
|
13
|
+
const stepsVariants = cva("steps flex", {
|
|
14
|
+
variants: {
|
|
15
|
+
orientation: {
|
|
16
|
+
horizontal: "w-full flex-row items-start",
|
|
17
|
+
vertical: "flex-col",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
orientation: "horizontal",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type StepsOrientation = "horizontal" | "vertical";
|
|
26
|
+
|
|
27
|
+
/** Where a step sits relative to `current`. Rendered as `data-state`. */
|
|
28
|
+
export type StepState = "complete" | "current" | "upcoming";
|
|
29
|
+
|
|
30
|
+
export type StepItem = {
|
|
31
|
+
/** Visible step name. */
|
|
32
|
+
label: React.ReactNode;
|
|
33
|
+
/** Secondary line under the label. */
|
|
34
|
+
description?: React.ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type StepsProps = Omit<React.OlHTMLAttributes<HTMLOListElement>, "children"> & {
|
|
38
|
+
/** The steps, in order. */
|
|
39
|
+
steps: readonly StepItem[];
|
|
40
|
+
/** Zero-based index of the step in progress. `steps.length` marks every step complete. */
|
|
41
|
+
current: number;
|
|
42
|
+
/**
|
|
43
|
+
* Makes completed steps clickable, for going back in a wizard. Upcoming
|
|
44
|
+
* steps never are — skipping ahead is the flow's decision, not the
|
|
45
|
+
* indicator's.
|
|
46
|
+
*/
|
|
47
|
+
onStepClick?: (index: number) => void;
|
|
48
|
+
/** Layout direction. `horizontal` collapses labels to the current step's on narrow screens. */
|
|
49
|
+
orientation?: StepsOrientation;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function stateOf(index: number, current: number): StepState {
|
|
53
|
+
if (index < current) return "complete";
|
|
54
|
+
if (index === current) return "current";
|
|
55
|
+
return "upcoming";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Progress through a fixed sequence — checkout, onboarding, a multi-page
|
|
60
|
+
* form. Display-only: the step in progress is the `current` prop, owned by
|
|
61
|
+
* whatever runs the flow, so there is no internal state to control.
|
|
62
|
+
*
|
|
63
|
+
* Renders an `<ol>` (pass `aria-label`, e.g. "Checkout progress"); the
|
|
64
|
+
* current item carries `aria-current="step"`, and each indicator has a
|
|
65
|
+
* visually hidden status ("completed", "current") so the sequence reads
|
|
66
|
+
* correctly without the colours.
|
|
67
|
+
*
|
|
68
|
+
* With `onStepClick`, completed steps become buttons. Current and upcoming
|
|
69
|
+
* steps stay plain text: a step indicator that let you jump forward would be
|
|
70
|
+
* making the flow's validation decisions for it.
|
|
71
|
+
*
|
|
72
|
+
* Horizontally, every label shows from `sm` up; below that only the current
|
|
73
|
+
* step's label does, so four steps still fit a phone.
|
|
74
|
+
*/
|
|
75
|
+
export const Steps = React.forwardRef<HTMLOListElement, StepsProps>(({ className, steps, current, onStepClick, orientation = "horizontal", ...props }, ref) => {
|
|
76
|
+
const isHorizontal = orientation === "horizontal";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ol ref={ref} data-orientation={orientation} className={stepsVariants({ orientation, className })} {...props}>
|
|
80
|
+
{steps.map((step, index) => {
|
|
81
|
+
const state = stateOf(index, current);
|
|
82
|
+
const isLast = index === steps.length - 1;
|
|
83
|
+
const clickable = state === "complete" && onStepClick !== undefined;
|
|
84
|
+
|
|
85
|
+
const indicator = (
|
|
86
|
+
<span className="steps-indicator border-(color:--steps-indicator-border) relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border-2 bg-(--steps-indicator-bg) font-semibold text-(--steps-indicator-fg) text-sm transition-colors">
|
|
87
|
+
{state === "complete" ? <Check aria-hidden="true" className="size-4" strokeWidth={3} /> : index + 1}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const text = (
|
|
92
|
+
<span
|
|
93
|
+
className={cn("flex min-w-0 flex-col", isHorizontal ? "items-center text-center" : "pt-1", isHorizontal && state !== "current" && "max-sm:sr-only")}
|
|
94
|
+
>
|
|
95
|
+
<span className="font-medium text-(--steps-label-fg) text-sm">{step.label}</span>
|
|
96
|
+
{step.description !== undefined && <span className="text-(--steps-description-fg) text-xs">{step.description}</span>}
|
|
97
|
+
{state !== "upcoming" && <span className="sr-only">{state === "complete" ? "(completed)" : "(current)"}</span>}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const body = (
|
|
102
|
+
<>
|
|
103
|
+
{indicator}
|
|
104
|
+
{text}
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const bodyClass = cn("flex gap-2", isHorizontal ? "flex-col items-center" : "flex-row items-start gap-3");
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<li
|
|
112
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a step is its position — the sequence is fixed and never reorders
|
|
113
|
+
key={index}
|
|
114
|
+
data-state={state}
|
|
115
|
+
aria-current={state === "current" ? "step" : undefined}
|
|
116
|
+
className={cn("steps-item relative", isHorizontal ? "flex flex-1 justify-center" : "flex pb-6 last:pb-0")}
|
|
117
|
+
>
|
|
118
|
+
{!isLast && (
|
|
119
|
+
<span
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
className={cn(
|
|
122
|
+
"steps-connector absolute bg-(--steps-connector-bg)",
|
|
123
|
+
isHorizontal ? "top-4 left-1/2 h-0.5 w-full -translate-y-1/2" : "top-8 bottom-0 left-4 w-0.5 -translate-x-1/2"
|
|
124
|
+
)}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
{clickable ? (
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => onStepClick(index)}
|
|
131
|
+
className={cn(
|
|
132
|
+
bodyClass,
|
|
133
|
+
"focus-visible:ring-(color:--steps-ring) cursor-pointer rounded-md focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 [&:hover_.steps-indicator]:bg-(--steps-indicator-bg-hovered)"
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{body}
|
|
137
|
+
</button>
|
|
138
|
+
) : (
|
|
139
|
+
<span className={bodyClass}>{body}</span>
|
|
140
|
+
)}
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
Steps.displayName = "Steps";
|
|
148
|
+
|
|
149
|
+
export { stepsVariants };
|