@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
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { Upload } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ /**
8
+ * Geometry only. Every colour reads a `--file-dropzone-*` component token
9
+ * whose value is decided by src/components/FileDropzone/file-dropzone.recipe.css.
10
+ *
11
+ * `size` is the target's footprint: `sm` sits inline in a form row beside other
12
+ * fields, `md` is a panel of its own (an image manager's "add photos" tile).
13
+ */
14
+ const fileDropzoneVariants = cva(
15
+ // One literal, deliberately — see Button.tsx for why a `+` concatenation is a trap.
16
+ "file-dropzone border-(color:--file-dropzone-border) focus-visible:ring-(color:--file-dropzone-ring) relative flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-(--file-dropzone-bg) text-center text-(--file-dropzone-fg) transition-colors hover:bg-(--file-dropzone-bg-hovered) focus-visible:outline-hidden focus-visible:ring-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
17
+ {
18
+ variants: {
19
+ size: {
20
+ sm: "min-h-20 gap-1 px-4 py-3 text-xs [&_svg]:size-4",
21
+ md: "min-h-36 gap-2 px-6 py-6 text-sm [&_svg]:size-6",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ size: "md",
26
+ },
27
+ }
28
+ );
29
+
30
+ export type FileDropzoneSize = NonNullable<VariantProps<typeof fileDropzoneVariants>["size"]>;
31
+
32
+ export type FileDropzoneRejection = {
33
+ file: File;
34
+ /** `type` — not in `accept`; `size` — over `maxSize`. */
35
+ reason: "type" | "size";
36
+ };
37
+
38
+ export type FileDropzoneProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "children"> &
39
+ VariantProps<typeof fileDropzoneVariants> & {
40
+ /** Called with the accepted files from a drop or the file picker. Never called with an empty list. */
41
+ onFilesChange: (files: File[]) => void;
42
+ /** Called with files that failed `accept` or `maxSize`, when any did. */
43
+ onFilesRejected?: (rejections: FileDropzoneRejection[]) => void;
44
+ /** Same syntax as the native `accept` attribute: `"image/*"`, `".pdf,.docx"`. Also checked on drop. */
45
+ accept?: string;
46
+ /** Allow more than one file per drop or pick. */
47
+ multiple?: boolean;
48
+ /** Largest accepted file, in bytes. */
49
+ maxSize?: number;
50
+ disabled?: boolean;
51
+ /** Shows a busy state (e.g. while uploading) and blocks new files without disabling the control. */
52
+ pending?: boolean;
53
+ /** The main line. Default: "Drop files here or click to browse". */
54
+ label?: React.ReactNode;
55
+ /** A secondary line, e.g. accepted types and size limit. */
56
+ hint?: React.ReactNode;
57
+ /** Replaces the default upload icon. Pass `null` for none. */
58
+ icon?: React.ReactNode;
59
+ /** Input name, for native form submission of the picked files. */
60
+ name?: string;
61
+ };
62
+
63
+ function matchesAccept(file: File, accept: string | undefined): boolean {
64
+ if (!accept) return true;
65
+ const rules = accept
66
+ .split(",")
67
+ .map((rule) => rule.trim().toLowerCase())
68
+ .filter(Boolean);
69
+ if (rules.length === 0) return true;
70
+ const name = file.name.toLowerCase();
71
+ const type = file.type.toLowerCase();
72
+ return rules.some((rule) => {
73
+ if (rule.startsWith(".")) return name.endsWith(rule);
74
+ if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
75
+ return type === rule;
76
+ });
77
+ }
78
+
79
+ /**
80
+ * A drop target for files that doubles as a file-picker button — product
81
+ * photos, attachments, imports.
82
+ *
83
+ * The whole surface is one control: `role="button"`, a tab stop, and `Enter`
84
+ * or `Space` opens the native picker, the same as a click. Dragging files over
85
+ * it sets `data-state="active"`, which the recipe styles. The hidden `<input
86
+ * type="file">` is the real picker, so `accept`, `multiple` and `name` behave
87
+ * natively; `accept` and `maxSize` are also enforced on drop, where the
88
+ * browser does not, and failures go to `onFilesRejected` rather than being
89
+ * silently dropped. Without `multiple`, a drop of several files keeps the first.
90
+ *
91
+ * `pending` shows a spinner-free busy state (`aria-busy`) and ignores new
92
+ * files while an upload runs, without leaving the tab order — the same split
93
+ * Switch makes between `pending` and `disabled`.
94
+ *
95
+ * The component holds no file list of its own: it reports files and the
96
+ * caller decides what to show, so there is no value/defaultValue triad.
97
+ */
98
+ export const FileDropzone = React.forwardRef<HTMLDivElement, FileDropzoneProps>(
99
+ (
100
+ {
101
+ className,
102
+ size = "md",
103
+ onFilesChange,
104
+ onFilesRejected,
105
+ accept,
106
+ multiple = false,
107
+ maxSize,
108
+ disabled = false,
109
+ pending = false,
110
+ label = "Drop files here or click to browse",
111
+ hint,
112
+ icon,
113
+ name,
114
+ onKeyDown,
115
+ onClick,
116
+ ...props
117
+ },
118
+ ref
119
+ ) => {
120
+ const inputRef = React.useRef<HTMLInputElement>(null);
121
+ const [active, setActive] = React.useState(false);
122
+ // dragenter/dragleave fire for every child crossed; count them so the state does not flicker.
123
+ const depth = React.useRef(0);
124
+ const labelId = React.useId();
125
+ const hintId = React.useId();
126
+
127
+ const interactive = !disabled && !pending;
128
+
129
+ const handleFiles = (list: FileList | null) => {
130
+ if (!list || !interactive) return;
131
+ const files = Array.from(list);
132
+ const accepted: File[] = [];
133
+ const rejected: FileDropzoneRejection[] = [];
134
+ for (const file of files) {
135
+ if (!matchesAccept(file, accept)) rejected.push({ file, reason: "type" });
136
+ else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: "size" });
137
+ else accepted.push(file);
138
+ }
139
+ const kept = multiple ? accepted : accepted.slice(0, 1);
140
+ if (rejected.length > 0) onFilesRejected?.(rejected);
141
+ if (kept.length > 0) onFilesChange(kept);
142
+ };
143
+
144
+ const openPicker = () => {
145
+ if (interactive) inputRef.current?.click();
146
+ };
147
+
148
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
149
+ onKeyDown?.(event);
150
+ if (event.defaultPrevented || event.target !== event.currentTarget) return;
151
+ if (event.key === "Enter" || event.key === " ") {
152
+ event.preventDefault();
153
+ openPicker();
154
+ }
155
+ };
156
+
157
+ const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
158
+ event.preventDefault();
159
+ if (!interactive) return;
160
+ depth.current += 1;
161
+ setActive(true);
162
+ };
163
+
164
+ const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
165
+ event.preventDefault();
166
+ depth.current = Math.max(0, depth.current - 1);
167
+ if (depth.current === 0) setActive(false);
168
+ };
169
+
170
+ const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
171
+ event.preventDefault();
172
+ event.dataTransfer.dropEffect = interactive ? "copy" : "none";
173
+ };
174
+
175
+ const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
176
+ event.preventDefault();
177
+ depth.current = 0;
178
+ setActive(false);
179
+ handleFiles(event.dataTransfer.files);
180
+ };
181
+
182
+ return (
183
+ // biome-ignore lint/a11y/useSemanticElements: the target holds the native file <input>, and interactive content is not allowed inside a <button>; role="button" with Enter/Space handling is the WAI-ARIA button pattern
184
+ <div
185
+ ref={ref}
186
+ role="button"
187
+ tabIndex={disabled ? -1 : 0}
188
+ aria-disabled={disabled || undefined}
189
+ aria-busy={pending || undefined}
190
+ aria-labelledby={labelId}
191
+ aria-describedby={hint ? hintId : undefined}
192
+ data-state={active ? "active" : "idle"}
193
+ data-disabled={disabled}
194
+ className={fileDropzoneVariants({ size, className })}
195
+ onClick={(event) => {
196
+ onClick?.(event);
197
+ if (!event.defaultPrevented) openPicker();
198
+ }}
199
+ onKeyDown={handleKeyDown}
200
+ onDragEnter={handleDragEnter}
201
+ onDragLeave={handleDragLeave}
202
+ onDragOver={handleDragOver}
203
+ onDrop={handleDrop}
204
+ {...props}
205
+ >
206
+ {icon === undefined ? <Upload aria-hidden="true" className="text-(--file-dropzone-icon-fg)" /> : icon}
207
+ <span id={labelId} className="font-medium">
208
+ {label}
209
+ </span>
210
+ {hint && (
211
+ <span id={hintId} className="text-(--file-dropzone-hint-fg)">
212
+ {hint}
213
+ </span>
214
+ )}
215
+ <input
216
+ ref={inputRef}
217
+ type="file"
218
+ // `hidden`, not `sr-only`: a rendered input inside role="button" is a nested
219
+ // interactive control (axe `nested-interactive`). `click()` and form submission
220
+ // still work on a display:none file input.
221
+ hidden
222
+ accept={accept}
223
+ multiple={multiple}
224
+ name={name}
225
+ disabled={disabled}
226
+ onClick={(event) => event.stopPropagation()}
227
+ onChange={(event) => {
228
+ handleFiles(event.target.files);
229
+ // Reset so picking the same file again still fires change.
230
+ event.target.value = "";
231
+ }}
232
+ />
233
+ </div>
234
+ );
235
+ }
236
+ );
237
+ FileDropzone.displayName = "FileDropzone";
238
+
239
+ export { fileDropzoneVariants };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * FileDropzone recipe — the component-token layer for FileDropzone.
3
+ *
4
+ * No variant/intent axis: a dropzone is a form control, so like Input every
5
+ * colour is a fixed semantic token. The one state that changes colour is a
6
+ * drag in progress, which the component renders as `data-state="active"`
7
+ * and the recipe keys a cell on — the same way steps.css keys on `data-state`.
8
+ *
9
+ * The grammar is `--file-dropzone-[{part}-]{context}[-{state}]`. Parts are
10
+ * the `icon` and the `hint` line. The surface reads the subtle ground so the
11
+ * dashed target stands apart from the fields around it, and a drag over it
12
+ * takes the brand wash — the same pairing Tabs uses for its selected cell.
13
+ *
14
+ * `@layer components` is load-bearing, not tidiness — see button.css.
15
+ */
16
+
17
+ @layer components {
18
+ .file-dropzone {
19
+ --file-dropzone-bg: var(--bg-subtle);
20
+ --file-dropzone-bg-hovered: var(--bg-subtle-hovered);
21
+ --file-dropzone-fg: var(--fg-normal);
22
+ --file-dropzone-border: var(--border-normal);
23
+ --file-dropzone-ring: var(--focus-ring);
24
+ --file-dropzone-icon-fg: var(--fg-subtle);
25
+ --file-dropzone-hint-fg: var(--fg-subtle);
26
+ }
27
+
28
+ /* A drag in progress: the brand wash, hover included, so the target does not
29
+ flicker back to subtle as the pointer moves across it. */
30
+ .file-dropzone[data-state="active"] {
31
+ --file-dropzone-bg: var(--bg-brand-subtle);
32
+ --file-dropzone-bg-hovered: var(--bg-brand-subtle);
33
+ --file-dropzone-border: var(--border-brand);
34
+ --file-dropzone-icon-fg: var(--fg-brand);
35
+ /* fg-subtle drops below 4.5:1 on the brand wash in light mode. */
36
+ --file-dropzone-hint-fg: var(--fg-normal);
37
+ }
38
+ }
@@ -0,0 +1 @@
1
+ export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejection, type FileDropzoneSize, fileDropzoneVariants } from "./FileDropzone";
@@ -3,6 +3,7 @@
3
3
  import { autoUpdate, flip, offset, type Placement, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions } from "@floating-ui/react";
4
4
  import { useControllableState } from "@sixthshift/design-system/hooks";
5
5
  import type * as React from "react";
6
+ import { closestBrand } from "../../internal/brandScope";
6
7
  import { HoverCardContent } from "./components/HoverCardContent";
7
8
  import { HoverCardContext } from "./components/HoverCardContext";
8
9
  import { HoverCardTrigger } from "./components/HoverCardTrigger";
@@ -67,6 +68,8 @@ const HoverCardRoot = ({
67
68
  setFloating: refs.setFloating,
68
69
  },
69
70
  floatingStyles,
71
+ // Read at render: `domReference` is state, so this updates once the trigger mounts.
72
+ inheritedBrand: closestBrand(context.elements.domReference),
70
73
  getReferenceProps,
71
74
  getFloatingProps,
72
75
  };
@@ -5,10 +5,17 @@ import { cn } from "@sixthshift/design-system/utils";
5
5
  import * as React from "react";
6
6
  import { useHoverCardContext } from "./HoverCardContext";
7
7
 
8
- export type HoverCardContentProps = React.HTMLAttributes<HTMLDivElement>;
8
+ export type HoverCardContentProps = 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 HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(({ className, children, ...props }, forwardedRef) => {
11
- const { open, refs, floatingStyles, getFloatingProps } = useHoverCardContext();
17
+ export const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(({ className, brand, children, ...props }, forwardedRef) => {
18
+ const { open, refs, floatingStyles, inheritedBrand, getFloatingProps } = useHoverCardContext();
12
19
 
13
20
  if (!open) return null;
14
21
 
@@ -21,6 +28,7 @@ export const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardConten
21
28
  else if (forwardedRef) forwardedRef.current = node;
22
29
  }}
23
30
  style={floatingStyles}
31
+ data-brand={brand ?? inheritedBrand}
24
32
  className={cn("z-popover w-80 rounded-lg border border-border-normal bg-bg-normal p-4 shadow-lg", className)}
25
33
  {...getFloatingProps(props)}
26
34
  >
@@ -9,6 +9,8 @@ export type HoverCardContextValue = {
9
9
  setFloating: (node: HTMLElement | null) => void;
10
10
  };
11
11
  floatingStyles: React.CSSProperties;
12
+ /** The trigger's brand scope, re-stamped on the portalled body. */
13
+ inheritedBrand: string | undefined;
12
14
  getReferenceProps: (props?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
13
15
  getFloatingProps: (props?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
14
16
  };
@@ -0,0 +1,285 @@
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 { ChevronLeft, ChevronRight } from "lucide-react";
7
+ import * as React from "react";
8
+
9
+ /**
10
+ * Geometry only. Every colour reads an `--image-gallery-*` component token
11
+ * whose value is decided by src/components/ImageGallery/image-gallery.recipe.css.
12
+ *
13
+ * `ratio` is the frame's shape, not the images': every slide is cropped to it
14
+ * with `object-cover`, so a mixed set of uploads still produces a steady
15
+ * frame that does not jump as you swipe. `portrait` is 4:5, the tallest crop
16
+ * Instagram allows in a feed — photography shot for the feed lands on it
17
+ * uncropped.
18
+ */
19
+ const frameVariants = cva("relative overflow-hidden rounded-lg bg-(--image-gallery-bg)", {
20
+ variants: {
21
+ ratio: {
22
+ square: "aspect-square",
23
+ portrait: "aspect-[4/5]",
24
+ landscape: "aspect-[4/3]",
25
+ wide: "aspect-video",
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ ratio: "square",
30
+ },
31
+ });
32
+
33
+ export type ImageGalleryRatio = NonNullable<VariantProps<typeof frameVariants>["ratio"]>;
34
+
35
+ export type ImageGalleryImage = {
36
+ src: string;
37
+ /** Required: describe the photo. The gallery adds position ("2 of 5") on its own. */
38
+ alt: string;
39
+ srcSet?: string;
40
+ sizes?: string;
41
+ width?: number;
42
+ height?: number;
43
+ };
44
+
45
+ export type ImageGalleryProps = Omit<React.HTMLAttributes<HTMLElement>, "onChange"> &
46
+ VariantProps<typeof frameVariants> & {
47
+ images: readonly ImageGalleryImage[];
48
+ /** Controlled index of the image shown. */
49
+ index?: number;
50
+ /** Initial index in uncontrolled mode. */
51
+ defaultIndex?: number;
52
+ /** Called when the shown image changes — by swipe, arrow, key or thumbnail. */
53
+ onIndexChange?: (index: number) => void;
54
+ /**
55
+ * What sits under the frame: a strip of `thumbnails`, a row of `dots`, or
56
+ * `none`. Only rendered when there is more than one image.
57
+ */
58
+ indicator?: "thumbnails" | "dots" | "none";
59
+ /** Accessible name for the carousel, e.g. the product's name. */
60
+ label?: string;
61
+ /** Accessible name for the previous-image button. */
62
+ previousLabel?: string;
63
+ /** Accessible name for the next-image button. */
64
+ nextLabel?: string;
65
+ /** Shown in the frame when `images` is empty. */
66
+ placeholder?: React.ReactNode;
67
+ };
68
+
69
+ function prefersReducedMotion(): boolean {
70
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
71
+ }
72
+
73
+ /**
74
+ * A product photo carousel: one frame, swipeable on touch, with previous/next
75
+ * buttons, arrow-key navigation and an optional thumbnail strip or dots.
76
+ *
77
+ * Swiping is native scrolling — a horizontal `scroll-snap` track, not a
78
+ * pointer-event reimplementation — so it has the platform's own momentum and
79
+ * edge behaviour on phones. The shown index follows the scroll position, and
80
+ * setting the index (by button, key, thumbnail or a controlled `index`)
81
+ * scrolls the track. Both directions meet at one rule: the effect only scrolls
82
+ * when the track is not already showing the index, so a swipe that updates
83
+ * the index never fights itself.
84
+ *
85
+ * Controlled or uncontrolled through `index`/`defaultIndex`/`onIndexChange`.
86
+ *
87
+ * Follows the WAI-ARIA carousel pattern: the root is a `<section>` with
88
+ * `aria-roledescription="carousel"` (a landmark region once `label` names
89
+ * it), each slide a group named "n of total", and slides out of view are
90
+ * `aria-hidden`. The first image loads eagerly and
91
+ * the rest lazily, so a gallery of ten costs one download up front.
92
+ */
93
+ export const ImageGallery = React.forwardRef<HTMLElement, ImageGalleryProps>(
94
+ (
95
+ {
96
+ className,
97
+ images,
98
+ index: controlledIndex,
99
+ defaultIndex = 0,
100
+ onIndexChange,
101
+ ratio = "square",
102
+ indicator = "thumbnails",
103
+ label,
104
+ previousLabel = "Previous image",
105
+ nextLabel = "Next image",
106
+ placeholder,
107
+ onKeyDown,
108
+ ...props
109
+ },
110
+ ref
111
+ ) => {
112
+ const count = images.length;
113
+ const [rawIndex, setIndex] = useControllableState({
114
+ value: controlledIndex,
115
+ defaultValue: defaultIndex,
116
+ onChange: onIndexChange,
117
+ });
118
+ const index = count === 0 ? 0 : Math.min(Math.max(rawIndex, 0), count - 1);
119
+
120
+ const trackRef = React.useRef<HTMLDivElement | null>(null);
121
+ // The slide a programmatic scroll is heading for. While set, the slides it
122
+ // passes on the way are not reported — jumping from 1 to 4 reports 4, not
123
+ // 2, 3 and 4. Any touch, wheel or pointer on the track hands control back.
124
+ const scrollTargetRef = React.useRef<number | null>(null);
125
+
126
+ // Index → scroll. Skipped when the track already shows `index`, which is
127
+ // always the case right after a swipe reported it.
128
+ React.useEffect(() => {
129
+ const track = trackRef.current;
130
+ if (!track || track.clientWidth === 0) return;
131
+ if (Math.round(track.scrollLeft / track.clientWidth) === index) return;
132
+ scrollTargetRef.current = index;
133
+ track.scrollTo({ left: index * track.clientWidth, behavior: prefersReducedMotion() ? "auto" : "smooth" });
134
+ }, [index]);
135
+
136
+ // Scroll → index. Rounds, so the index flips at the halfway point of a swipe.
137
+ const handleScroll = () => {
138
+ const track = trackRef.current;
139
+ if (!track || track.clientWidth === 0) return;
140
+ const shown = Math.round(track.scrollLeft / track.clientWidth);
141
+ if (scrollTargetRef.current !== null) {
142
+ if (shown !== scrollTargetRef.current) return;
143
+ scrollTargetRef.current = null;
144
+ }
145
+ if (shown !== index && shown >= 0 && shown < count) setIndex(shown);
146
+ };
147
+
148
+ const releaseScroll = () => {
149
+ scrollTargetRef.current = null;
150
+ };
151
+
152
+ const go = (next: number) => {
153
+ if (next >= 0 && next < count && next !== index) setIndex(next);
154
+ };
155
+
156
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
157
+ onKeyDown?.(event);
158
+ if (event.defaultPrevented) return;
159
+ const targets: Record<string, number> = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: count - 1 };
160
+ const target = targets[event.key];
161
+ if (target === undefined) return;
162
+ event.preventDefault();
163
+ go(target);
164
+ };
165
+
166
+ const controlClass =
167
+ "image-gallery-control focus-visible:ring-(color:--image-gallery-control-ring) absolute top-1/2 flex size-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-(--image-gallery-control-bg) text-(--image-gallery-control-fg) shadow transition-opacity hover:bg-(--image-gallery-control-bg-hovered) focus-visible:outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-0 max-sm:hidden [&_svg]:size-5";
168
+
169
+ return (
170
+ <section
171
+ ref={ref}
172
+ aria-roledescription="carousel"
173
+ aria-label={label}
174
+ className={cn("image-gallery flex flex-col gap-3", className)}
175
+ onKeyDown={handleKeyDown}
176
+ {...props}
177
+ >
178
+ <div className={frameVariants({ ratio })}>
179
+ {count === 0 ? (
180
+ <div className="flex h-full w-full items-center justify-center text-(--image-gallery-placeholder-fg) text-sm">{placeholder}</div>
181
+ ) : (
182
+ <div
183
+ ref={trackRef}
184
+ onScroll={handleScroll}
185
+ onPointerDown={releaseScroll}
186
+ onTouchStart={releaseScroll}
187
+ onWheel={releaseScroll}
188
+ // Focusable so the arrow keys work without first reaching a button,
189
+ // and because a scrollable region must be keyboard-reachable.
190
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable regions must be focusable (axe `scrollable-region-focusable`)
191
+ tabIndex={0}
192
+ className="focus-visible:ring-(color:--image-gallery-control-ring) flex h-full w-full snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset [&::-webkit-scrollbar]:hidden"
193
+ >
194
+ {images.map((image, i) => (
195
+ // biome-ignore lint/a11y/useSemanticElements: the WAI-ARIA carousel pattern names each slide a `group`; a <fieldset> is for form controls
196
+ <div
197
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
198
+ key={i}
199
+ role="group"
200
+ aria-roledescription="slide"
201
+ aria-label={`${i + 1} of ${count}`}
202
+ aria-hidden={i !== index}
203
+ className="h-full w-full shrink-0 snap-center snap-always"
204
+ >
205
+ <img
206
+ src={image.src}
207
+ srcSet={image.srcSet}
208
+ sizes={image.sizes}
209
+ width={image.width}
210
+ height={image.height}
211
+ alt={image.alt}
212
+ loading={i === 0 ? "eager" : "lazy"}
213
+ decoding="async"
214
+ draggable={false}
215
+ className="h-full w-full select-none object-cover"
216
+ />
217
+ </div>
218
+ ))}
219
+ </div>
220
+ )}
221
+ {count > 1 && (
222
+ <>
223
+ <button type="button" aria-label={previousLabel} disabled={index === 0} onClick={() => go(index - 1)} className={cn(controlClass, "left-2")}>
224
+ <ChevronLeft aria-hidden="true" />
225
+ </button>
226
+ <button type="button" aria-label={nextLabel} disabled={index === count - 1} onClick={() => go(index + 1)} className={cn(controlClass, "right-2")}>
227
+ <ChevronRight aria-hidden="true" />
228
+ </button>
229
+ </>
230
+ )}
231
+ </div>
232
+
233
+ {count > 1 && indicator === "thumbnails" && (
234
+ <div className="flex gap-2 overflow-x-auto p-0.5">
235
+ {images.map((image, i) => (
236
+ <button
237
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
238
+ key={i}
239
+ type="button"
240
+ aria-label={`Show image ${i + 1} of ${count}`}
241
+ aria-current={i === index ? "true" : undefined}
242
+ data-selected={i === index}
243
+ onClick={() => go(i)}
244
+ className="image-gallery-thumb border-(color:--image-gallery-thumb-border) focus-visible:ring-(color:--image-gallery-control-ring) hover:border-(color:--image-gallery-thumb-border-hovered) size-16 shrink-0 cursor-pointer overflow-hidden rounded-md border-2 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
245
+ >
246
+ <img
247
+ src={image.src}
248
+ srcSet={image.srcSet}
249
+ sizes="64px"
250
+ alt=""
251
+ loading="lazy"
252
+ decoding="async"
253
+ draggable={false}
254
+ className="h-full w-full object-cover"
255
+ />
256
+ </button>
257
+ ))}
258
+ </div>
259
+ )}
260
+
261
+ {count > 1 && indicator === "dots" && (
262
+ <div className="flex justify-center gap-1.5">
263
+ {images.map((_, i) => (
264
+ <button
265
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
266
+ key={i}
267
+ type="button"
268
+ aria-label={`Show image ${i + 1} of ${count}`}
269
+ aria-current={i === index ? "true" : undefined}
270
+ data-selected={i === index}
271
+ onClick={() => go(i)}
272
+ className="image-gallery-dot focus-visible:ring-(color:--image-gallery-control-ring) flex size-6 cursor-pointer items-center justify-center rounded-full focus-visible:outline-hidden focus-visible:ring-2"
273
+ >
274
+ <span aria-hidden="true" className="block size-2 rounded-full bg-(--image-gallery-dot-bg) transition-colors" />
275
+ </button>
276
+ ))}
277
+ </div>
278
+ )}
279
+ </section>
280
+ );
281
+ }
282
+ );
283
+ ImageGallery.displayName = "ImageGallery";
284
+
285
+ export { frameVariants as imageGalleryFrameVariants };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ImageGallery recipe — the component-token layer for ImageGallery.
3
+ *
4
+ * No variant/intent axis. The one selectable cell is `data-selected` on a
5
+ * thumbnail or dot, the same shape tabs.css uses for its selected trigger.
6
+ *
7
+ * The grammar is `--image-gallery-[{part}-]{context}[-{state}]`. Parts:
8
+ *
9
+ * control the previous/next buttons floating over the photo. They sit
10
+ * on arbitrary imagery, so they take the page's resting surface
11
+ * with an elevation shadow rather than a transparent ghost —
12
+ * legible over a white cake and a chocolate one alike.
13
+ * thumb a thumbnail button's frame.
14
+ * dot a dot indicator's fill.
15
+ * placeholder the text shown when there are no images.
16
+ *
17
+ * `--image-gallery-bg` shows while a lazy image loads and behind an empty
18
+ * gallery, so it is the subtle surface, not the page's.
19
+ *
20
+ * `@layer components` is load-bearing, not tidiness — see button.css.
21
+ */
22
+
23
+ @layer components {
24
+ .image-gallery {
25
+ --image-gallery-bg: var(--bg-subtle);
26
+ --image-gallery-placeholder-fg: var(--fg-subtle);
27
+ --image-gallery-control-bg: var(--bg-normal);
28
+ --image-gallery-control-bg-hovered: var(--bg-normal-hovered);
29
+ --image-gallery-control-fg: var(--fg-normal);
30
+ --image-gallery-control-ring: var(--focus-ring);
31
+ }
32
+
33
+ .image-gallery-thumb {
34
+ --image-gallery-thumb-border: transparent;
35
+ --image-gallery-thumb-border-hovered: var(--border-strong);
36
+ }
37
+
38
+ .image-gallery-thumb[data-selected="true"] {
39
+ --image-gallery-thumb-border: var(--border-brand);
40
+ --image-gallery-thumb-border-hovered: var(--image-gallery-thumb-border);
41
+ }
42
+
43
+ .image-gallery-dot {
44
+ --image-gallery-dot-bg: var(--border-strong);
45
+ }
46
+
47
+ .image-gallery-dot[data-selected="true"] {
48
+ --image-gallery-dot-bg: var(--bg-brand);
49
+ }
50
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ ImageGallery,
3
+ type ImageGalleryImage,
4
+ type ImageGalleryProps,
5
+ type ImageGalleryRatio,
6
+ imageGalleryFrameVariants,
7
+ } from "./ImageGallery";