@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.
Files changed (175) hide show
  1. package/dist/components/Badge/Badge.d.ts +10 -2
  2. package/dist/components/Badge/Badge.d.ts.map +1 -1
  3. package/dist/components/Badge/Badge.js +6 -4
  4. package/dist/components/Badge/Badge.js.map +1 -1
  5. package/dist/components/Button/Button.d.ts +25 -2
  6. package/dist/components/Button/Button.d.ts.map +1 -1
  7. package/dist/components/Button/Button.js +13 -4
  8. package/dist/components/Button/Button.js.map +1 -1
  9. package/dist/components/DataTable/DataTable.d.ts +89 -0
  10. package/dist/components/DataTable/DataTable.d.ts.map +1 -0
  11. package/dist/components/DataTable/DataTable.js +107 -0
  12. package/dist/components/DataTable/DataTable.js.map +1 -0
  13. package/dist/components/DataTable/index.d.ts +2 -0
  14. package/dist/components/DataTable/index.d.ts.map +1 -0
  15. package/dist/components/DataTable/index.js +2 -0
  16. package/dist/components/DataTable/index.js.map +1 -0
  17. package/dist/components/DatePicker/DatePicker.d.ts.map +1 -1
  18. package/dist/components/DatePicker/DatePicker.js +2 -1
  19. package/dist/components/DatePicker/DatePicker.js.map +1 -1
  20. package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  21. package/dist/components/DateTimePicker/DateTimePicker.js +2 -1
  22. package/dist/components/DateTimePicker/DateTimePicker.js.map +1 -1
  23. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.d.ts.map +1 -1
  24. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js +2 -1
  25. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js.map +1 -1
  26. package/dist/components/FileDropzone/FileDropzone.d.ts +87 -0
  27. package/dist/components/FileDropzone/FileDropzone.d.ts.map +1 -0
  28. package/dist/components/FileDropzone/FileDropzone.js +146 -0
  29. package/dist/components/FileDropzone/FileDropzone.js.map +1 -0
  30. package/dist/components/FileDropzone/index.d.ts +2 -0
  31. package/dist/components/FileDropzone/index.d.ts.map +1 -0
  32. package/dist/components/FileDropzone/index.js +2 -0
  33. package/dist/components/FileDropzone/index.js.map +1 -0
  34. package/dist/components/HoverCard/HoverCard.d.ts.map +1 -1
  35. package/dist/components/HoverCard/HoverCard.js +3 -0
  36. package/dist/components/HoverCard/HoverCard.js.map +1 -1
  37. package/dist/components/HoverCard/components/HoverCardContent.d.ts +16 -2
  38. package/dist/components/HoverCard/components/HoverCardContent.d.ts.map +1 -1
  39. package/dist/components/HoverCard/components/HoverCardContent.js +3 -3
  40. package/dist/components/HoverCard/components/HoverCardContent.js.map +1 -1
  41. package/dist/components/HoverCard/components/HoverCardContext.d.ts +2 -0
  42. package/dist/components/HoverCard/components/HoverCardContext.d.ts.map +1 -1
  43. package/dist/components/HoverCard/components/HoverCardContext.js.map +1 -1
  44. package/dist/components/ImageGallery/ImageGallery.d.ts +93 -0
  45. package/dist/components/ImageGallery/ImageGallery.d.ts.map +1 -0
  46. package/dist/components/ImageGallery/ImageGallery.js +121 -0
  47. package/dist/components/ImageGallery/ImageGallery.js.map +1 -0
  48. package/dist/components/ImageGallery/index.d.ts +2 -0
  49. package/dist/components/ImageGallery/index.d.ts.map +1 -0
  50. package/dist/components/ImageGallery/index.js +2 -0
  51. package/dist/components/ImageGallery/index.js.map +1 -0
  52. package/dist/components/Message/Message.d.ts +4 -0
  53. package/dist/components/Message/Message.d.ts.map +1 -1
  54. package/dist/components/Message/Message.js +2 -2
  55. package/dist/components/Message/Message.js.map +1 -1
  56. package/dist/components/Modal/Modal.d.ts +12 -0
  57. package/dist/components/Modal/Modal.d.ts.map +1 -1
  58. package/dist/components/Modal/Modal.js +5 -2
  59. package/dist/components/Modal/Modal.js.map +1 -1
  60. package/dist/components/NumberStepper/NumberStepper.d.ts +78 -0
  61. package/dist/components/NumberStepper/NumberStepper.d.ts.map +1 -0
  62. package/dist/components/NumberStepper/NumberStepper.js +129 -0
  63. package/dist/components/NumberStepper/NumberStepper.js.map +1 -0
  64. package/dist/components/NumberStepper/index.d.ts +2 -0
  65. package/dist/components/NumberStepper/index.d.ts.map +1 -0
  66. package/dist/components/NumberStepper/index.js +2 -0
  67. package/dist/components/NumberStepper/index.js.map +1 -0
  68. package/dist/components/Popover/Popover.d.ts +3 -1
  69. package/dist/components/Popover/Popover.d.ts.map +1 -1
  70. package/dist/components/Popover/Popover.js +3 -0
  71. package/dist/components/Popover/Popover.js.map +1 -1
  72. package/dist/components/Popover/components/PopoverBody.d.ts +16 -2
  73. package/dist/components/Popover/components/PopoverBody.d.ts.map +1 -1
  74. package/dist/components/Popover/components/PopoverBody.js +3 -3
  75. package/dist/components/Popover/components/PopoverBody.js.map +1 -1
  76. package/dist/components/Popover/components/PopoverContext.d.ts +2 -0
  77. package/dist/components/Popover/components/PopoverContext.d.ts.map +1 -1
  78. package/dist/components/Popover/components/PopoverContext.js.map +1 -1
  79. package/dist/components/Select/Select.d.ts.map +1 -1
  80. package/dist/components/Select/Select.js +3 -2
  81. package/dist/components/Select/Select.js.map +1 -1
  82. package/dist/components/Select/SelectDropdown.d.ts +3 -1
  83. package/dist/components/Select/SelectDropdown.d.ts.map +1 -1
  84. package/dist/components/Select/SelectDropdown.js +2 -2
  85. package/dist/components/Select/SelectDropdown.js.map +1 -1
  86. package/dist/components/Sheet/Sheet.d.ts +12 -0
  87. package/dist/components/Sheet/Sheet.d.ts.map +1 -1
  88. package/dist/components/Sheet/Sheet.js +4 -2
  89. package/dist/components/Sheet/Sheet.js.map +1 -1
  90. package/dist/components/Steps/Steps.d.ts +65 -0
  91. package/dist/components/Steps/Steps.d.ts.map +1 -0
  92. package/dist/components/Steps/Steps.js +62 -0
  93. package/dist/components/Steps/Steps.js.map +1 -0
  94. package/dist/components/Steps/index.d.ts +2 -0
  95. package/dist/components/Steps/index.d.ts.map +1 -0
  96. package/dist/components/Steps/index.js +2 -0
  97. package/dist/components/Steps/index.js.map +1 -0
  98. package/dist/components/TimePicker/TimePicker.d.ts.map +1 -1
  99. package/dist/components/TimePicker/TimePicker.js +2 -1
  100. package/dist/components/TimePicker/TimePicker.js.map +1 -1
  101. package/dist/components/Toast/Toast.d.ts.map +1 -1
  102. package/dist/components/Toast/Toast.js +2 -2
  103. package/dist/components/Toast/Toast.js.map +1 -1
  104. package/dist/components/Toggle/Toggle.d.ts +4 -0
  105. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  106. package/dist/components/Toggle/Toggle.js +2 -2
  107. package/dist/components/Toggle/Toggle.js.map +1 -1
  108. package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts +1 -0
  109. package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts.map +1 -1
  110. package/dist/components/ToggleGroup/ToggleGroupMultiple.js +2 -2
  111. package/dist/components/ToggleGroup/ToggleGroupMultiple.js.map +1 -1
  112. package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts +1 -0
  113. package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts.map +1 -1
  114. package/dist/components/ToggleGroup/ToggleGroupSingle.js +2 -2
  115. package/dist/components/ToggleGroup/ToggleGroupSingle.js.map +1 -1
  116. package/dist/components/ToggleGroup/toggleGroup.types.d.ts +6 -0
  117. package/dist/components/ToggleGroup/toggleGroup.types.d.ts.map +1 -1
  118. package/dist/components/Tooltip/Tooltip.d.ts +3 -1
  119. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -1
  120. package/dist/components/Tooltip/Tooltip.js +3 -0
  121. package/dist/components/Tooltip/Tooltip.js.map +1 -1
  122. package/dist/components/Tooltip/components/TooltipBody.d.ts +16 -2
  123. package/dist/components/Tooltip/components/TooltipBody.d.ts.map +1 -1
  124. package/dist/components/Tooltip/components/TooltipBody.js +3 -3
  125. package/dist/components/Tooltip/components/TooltipBody.js.map +1 -1
  126. package/dist/components/Tooltip/components/TooltipContext.d.ts +2 -0
  127. package/dist/components/Tooltip/components/TooltipContext.d.ts.map +1 -1
  128. package/dist/components/Tooltip/components/TooltipContext.js.map +1 -1
  129. package/dist/internal/brandScope.d.ts +25 -0
  130. package/dist/internal/brandScope.d.ts.map +1 -0
  131. package/dist/internal/brandScope.js +37 -0
  132. package/dist/internal/brandScope.js.map +1 -0
  133. package/package.json +21 -1
  134. package/src/components/Badge/Badge.tsx +10 -4
  135. package/src/components/Button/Button.tsx +24 -4
  136. package/src/components/DataTable/DataTable.tsx +261 -0
  137. package/src/components/DataTable/data-table.recipe.css +28 -0
  138. package/src/components/DataTable/index.ts +10 -0
  139. package/src/components/DatePicker/DatePicker.tsx +3 -0
  140. package/src/components/DateTimePicker/DateTimePicker.tsx +3 -0
  141. package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +3 -0
  142. package/src/components/FileDropzone/FileDropzone.tsx +239 -0
  143. package/src/components/FileDropzone/file-dropzone.recipe.css +38 -0
  144. package/src/components/FileDropzone/index.ts +1 -0
  145. package/src/components/HoverCard/HoverCard.tsx +3 -0
  146. package/src/components/HoverCard/components/HoverCardContent.tsx +11 -3
  147. package/src/components/HoverCard/components/HoverCardContext.tsx +2 -0
  148. package/src/components/ImageGallery/ImageGallery.tsx +285 -0
  149. package/src/components/ImageGallery/image-gallery.recipe.css +50 -0
  150. package/src/components/ImageGallery/index.ts +7 -0
  151. package/src/components/Message/Message.tsx +26 -22
  152. package/src/components/Modal/Modal.tsx +11 -0
  153. package/src/components/NumberStepper/NumberStepper.tsx +227 -0
  154. package/src/components/NumberStepper/index.ts +1 -0
  155. package/src/components/NumberStepper/number-stepper.recipe.css +31 -0
  156. package/src/components/Popover/Popover.tsx +3 -0
  157. package/src/components/Popover/components/PopoverBody.tsx +11 -3
  158. package/src/components/Popover/components/PopoverContext.tsx +2 -0
  159. package/src/components/Select/Select.tsx +3 -1
  160. package/src/components/Select/SelectDropdown.tsx +4 -0
  161. package/src/components/Sheet/Sheet.tsx +10 -0
  162. package/src/components/Steps/Steps.tsx +149 -0
  163. package/src/components/Steps/index.ts +1 -0
  164. package/src/components/Steps/steps.recipe.css +51 -0
  165. package/src/components/TimePicker/TimePicker.tsx +3 -0
  166. package/src/components/Toast/Toast.tsx +5 -1
  167. package/src/components/Toggle/Toggle.tsx +17 -2
  168. package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +2 -1
  169. package/src/components/ToggleGroup/ToggleGroupSingle.tsx +2 -0
  170. package/src/components/ToggleGroup/toggleGroup.types.ts +6 -0
  171. package/src/components/Tooltip/Tooltip.tsx +3 -0
  172. package/src/components/Tooltip/components/TooltipBody.tsx +11 -3
  173. package/src/components/Tooltip/components/TooltipContext.tsx +2 -0
  174. package/src/internal/brandScope.ts +39 -0
  175. 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>(({ className, intent = "neutral", size, title, icon, children, ...props }, ref) => {
103
- const hasCompoundChildren = React.Children.toArray(children).some(
104
- (child) =>
105
- React.isValidElement(child) &&
106
- (child.type === MessageIcon || child.type === MessageBody || child.type === MessageTitle || child.type === MessageDescription)
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
- return (
110
- <div ref={ref} role={intentRole(intent)} data-intent={intent} className={cn(messageVariants({ size, className }))} {...props}>
111
- {hasCompoundChildren ? (
112
- children
113
- ) : (
114
- <>
115
- {icon && <MessageIcon>{icon}</MessageIcon>}
116
- <MessageBody>
117
- {title && <MessageTitle>{title}</MessageTitle>}
118
- <MessageDescription>{children}</MessageDescription>
119
- </MessageBody>
120
- </>
121
- )}
122
- </div>
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 };