@sixthshift/design-system 0.6.0 → 0.7.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 (119) 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/DatePicker/DatePicker.d.ts.map +1 -1
  10. package/dist/components/DatePicker/DatePicker.js +2 -1
  11. package/dist/components/DatePicker/DatePicker.js.map +1 -1
  12. package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -1
  13. package/dist/components/DateTimePicker/DateTimePicker.js +2 -1
  14. package/dist/components/DateTimePicker/DateTimePicker.js.map +1 -1
  15. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.d.ts.map +1 -1
  16. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js +2 -1
  17. package/dist/components/DateTimeRangePicker/DateTimeRangePicker.js.map +1 -1
  18. package/dist/components/HoverCard/HoverCard.d.ts.map +1 -1
  19. package/dist/components/HoverCard/HoverCard.js +3 -0
  20. package/dist/components/HoverCard/HoverCard.js.map +1 -1
  21. package/dist/components/HoverCard/components/HoverCardContent.d.ts +16 -2
  22. package/dist/components/HoverCard/components/HoverCardContent.d.ts.map +1 -1
  23. package/dist/components/HoverCard/components/HoverCardContent.js +3 -3
  24. package/dist/components/HoverCard/components/HoverCardContent.js.map +1 -1
  25. package/dist/components/HoverCard/components/HoverCardContext.d.ts +2 -0
  26. package/dist/components/HoverCard/components/HoverCardContext.d.ts.map +1 -1
  27. package/dist/components/HoverCard/components/HoverCardContext.js.map +1 -1
  28. package/dist/components/Message/Message.d.ts +4 -0
  29. package/dist/components/Message/Message.d.ts.map +1 -1
  30. package/dist/components/Message/Message.js +2 -2
  31. package/dist/components/Message/Message.js.map +1 -1
  32. package/dist/components/Modal/Modal.d.ts +12 -0
  33. package/dist/components/Modal/Modal.d.ts.map +1 -1
  34. package/dist/components/Modal/Modal.js +5 -2
  35. package/dist/components/Modal/Modal.js.map +1 -1
  36. package/dist/components/Popover/Popover.d.ts +3 -1
  37. package/dist/components/Popover/Popover.d.ts.map +1 -1
  38. package/dist/components/Popover/Popover.js +3 -0
  39. package/dist/components/Popover/Popover.js.map +1 -1
  40. package/dist/components/Popover/components/PopoverBody.d.ts +16 -2
  41. package/dist/components/Popover/components/PopoverBody.d.ts.map +1 -1
  42. package/dist/components/Popover/components/PopoverBody.js +3 -3
  43. package/dist/components/Popover/components/PopoverBody.js.map +1 -1
  44. package/dist/components/Popover/components/PopoverContext.d.ts +2 -0
  45. package/dist/components/Popover/components/PopoverContext.d.ts.map +1 -1
  46. package/dist/components/Popover/components/PopoverContext.js.map +1 -1
  47. package/dist/components/Select/Select.d.ts.map +1 -1
  48. package/dist/components/Select/Select.js +3 -2
  49. package/dist/components/Select/Select.js.map +1 -1
  50. package/dist/components/Select/SelectDropdown.d.ts +3 -1
  51. package/dist/components/Select/SelectDropdown.d.ts.map +1 -1
  52. package/dist/components/Select/SelectDropdown.js +2 -2
  53. package/dist/components/Select/SelectDropdown.js.map +1 -1
  54. package/dist/components/Sheet/Sheet.d.ts +12 -0
  55. package/dist/components/Sheet/Sheet.d.ts.map +1 -1
  56. package/dist/components/Sheet/Sheet.js +4 -2
  57. package/dist/components/Sheet/Sheet.js.map +1 -1
  58. package/dist/components/TimePicker/TimePicker.d.ts.map +1 -1
  59. package/dist/components/TimePicker/TimePicker.js +2 -1
  60. package/dist/components/TimePicker/TimePicker.js.map +1 -1
  61. package/dist/components/Toast/Toast.d.ts.map +1 -1
  62. package/dist/components/Toast/Toast.js +2 -2
  63. package/dist/components/Toast/Toast.js.map +1 -1
  64. package/dist/components/Toggle/Toggle.d.ts +4 -0
  65. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  66. package/dist/components/Toggle/Toggle.js +2 -2
  67. package/dist/components/Toggle/Toggle.js.map +1 -1
  68. package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts +1 -0
  69. package/dist/components/ToggleGroup/ToggleGroupMultiple.d.ts.map +1 -1
  70. package/dist/components/ToggleGroup/ToggleGroupMultiple.js +2 -2
  71. package/dist/components/ToggleGroup/ToggleGroupMultiple.js.map +1 -1
  72. package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts +1 -0
  73. package/dist/components/ToggleGroup/ToggleGroupSingle.d.ts.map +1 -1
  74. package/dist/components/ToggleGroup/ToggleGroupSingle.js +2 -2
  75. package/dist/components/ToggleGroup/ToggleGroupSingle.js.map +1 -1
  76. package/dist/components/ToggleGroup/toggleGroup.types.d.ts +6 -0
  77. package/dist/components/ToggleGroup/toggleGroup.types.d.ts.map +1 -1
  78. package/dist/components/Tooltip/Tooltip.d.ts +3 -1
  79. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -1
  80. package/dist/components/Tooltip/Tooltip.js +3 -0
  81. package/dist/components/Tooltip/Tooltip.js.map +1 -1
  82. package/dist/components/Tooltip/components/TooltipBody.d.ts +16 -2
  83. package/dist/components/Tooltip/components/TooltipBody.d.ts.map +1 -1
  84. package/dist/components/Tooltip/components/TooltipBody.js +3 -3
  85. package/dist/components/Tooltip/components/TooltipBody.js.map +1 -1
  86. package/dist/components/Tooltip/components/TooltipContext.d.ts +2 -0
  87. package/dist/components/Tooltip/components/TooltipContext.d.ts.map +1 -1
  88. package/dist/components/Tooltip/components/TooltipContext.js.map +1 -1
  89. package/dist/internal/brandScope.d.ts +25 -0
  90. package/dist/internal/brandScope.d.ts.map +1 -0
  91. package/dist/internal/brandScope.js +37 -0
  92. package/dist/internal/brandScope.js.map +1 -0
  93. package/package.json +1 -1
  94. package/src/components/Badge/Badge.tsx +10 -4
  95. package/src/components/Button/Button.tsx +24 -4
  96. package/src/components/DatePicker/DatePicker.tsx +3 -0
  97. package/src/components/DateTimePicker/DateTimePicker.tsx +3 -0
  98. package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +3 -0
  99. package/src/components/HoverCard/HoverCard.tsx +3 -0
  100. package/src/components/HoverCard/components/HoverCardContent.tsx +11 -3
  101. package/src/components/HoverCard/components/HoverCardContext.tsx +2 -0
  102. package/src/components/Message/Message.tsx +26 -22
  103. package/src/components/Modal/Modal.tsx +11 -0
  104. package/src/components/Popover/Popover.tsx +3 -0
  105. package/src/components/Popover/components/PopoverBody.tsx +11 -3
  106. package/src/components/Popover/components/PopoverContext.tsx +2 -0
  107. package/src/components/Select/Select.tsx +3 -1
  108. package/src/components/Select/SelectDropdown.tsx +4 -0
  109. package/src/components/Sheet/Sheet.tsx +10 -0
  110. package/src/components/TimePicker/TimePicker.tsx +3 -0
  111. package/src/components/Toast/Toast.tsx +5 -1
  112. package/src/components/Toggle/Toggle.tsx +17 -2
  113. package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +2 -1
  114. package/src/components/ToggleGroup/ToggleGroupSingle.tsx +2 -0
  115. package/src/components/ToggleGroup/toggleGroup.types.ts +6 -0
  116. package/src/components/Tooltip/Tooltip.tsx +3 -0
  117. package/src/components/Tooltip/components/TooltipBody.tsx +11 -3
  118. package/src/components/Tooltip/components/TooltipContext.tsx +2 -0
  119. package/src/internal/brandScope.ts +39 -0
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ /**
4
+ * The brand an element sits in: the `data-brand` of it or its nearest
5
+ * ancestor, or `undefined` outside any scope.
6
+ *
7
+ * Overlays need this because a portal moves their content out from under the
8
+ * element that set `data-brand`, so CSS inheritance no longer carries it. The
9
+ * overlay reads the brand off its trigger and re-stamps it on its own element.
10
+ * The value is copied verbatim, unknown names included: the theme only gives
11
+ * listed names meaning, so an unknown one resolves exactly as it would have in
12
+ * place.
13
+ */
14
+ export function closestBrand(element) {
15
+ return element?.closest?.("[data-brand]")?.getAttribute("data-brand") ?? undefined;
16
+ }
17
+ const focusedBrand = () => (typeof document === "undefined" ? undefined : closestBrand(document.activeElement));
18
+ /**
19
+ * The brand of whatever held focus when the calling overlay opened.
20
+ *
21
+ * For Modal and Sheet, which have no reference element: the thing that opened
22
+ * them is almost always the focused control, and it still holds focus during
23
+ * the render that opens them, before the focus manager moves it. Captured on
24
+ * each closed → open transition and kept through the close, so neither focus
25
+ * moving into the dialog nor the exit animation changes it. When nothing useful
26
+ * was focused (Safari does not focus a clicked button) this is `undefined`, and
27
+ * the overlay inherits from wherever its portal landed.
28
+ */
29
+ export function useOpenerBrand(open) {
30
+ const [captured, setCaptured] = useState(() => ({ open, brand: open ? focusedBrand() : undefined }));
31
+ // Adjusting state during render, React's pattern for deriving from a prop
32
+ // change: an effect would run after the focus manager has already moved focus.
33
+ if (captured.open !== open)
34
+ setCaptured({ open, brand: open ? focusedBrand() : captured.brand });
35
+ return captured.brand;
36
+ }
37
+ //# sourceMappingURL=brandScope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brandScope.js","sourceRoot":"","sources":["../../src/internal/brandScope.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEjC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,YAAY,CAAC,OAAmC;IAC9D,OAAO,OAAO,EAAE,OAAO,EAAE,CAAC,cAAc,CAAC,EAAE,YAAY,CAAC,YAAY,CAAC,IAAI,SAAS,CAAC;AACrF,CAAC;AAED,MAAM,YAAY,GAAG,GAAG,EAAE,CAAC,CAAC,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC;AAEhH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,IAAa;IAC1C,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IACrG,0EAA0E;IAC1E,+EAA+E;IAC/E,IAAI,QAAQ,CAAC,IAAI,KAAK,IAAI;QAAE,WAAW,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IACjG,OAAO,QAAQ,CAAC,KAAK,CAAC;AACxB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixthshift/design-system",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Personal design system - tokens, theme, and component library",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -55,23 +55,29 @@ export type BadgeIntent = Loose<BadgeIntentName>;
55
55
  export type BadgeRecipeProps = VariantProps<typeof badgeVariants> & {
56
56
  variant?: BadgeVariant | undefined;
57
57
  intent?: BadgeIntent | undefined;
58
+ /** Re-points this element's brand tokens to a brand the theme defines. See the Scoped brands docs. */
59
+ brand?: string | undefined;
58
60
  className?: string | undefined;
59
61
  };
60
62
 
61
63
  /**
62
- * The class string plus the two attributes the recipe selects on.
64
+ * The class string plus the attributes the recipe selects on. `data-brand` is
65
+ * omitted unless `brand` is set, same as `buttonRecipe`.
63
66
  */
64
- export function badgeRecipe({ variant = "solid", intent = "brand", className }: BadgeRecipeProps) {
67
+ export function badgeRecipe({ variant = "solid", intent = "brand", brand, className }: BadgeRecipeProps) {
65
68
  return {
66
69
  className: cn(variantStructure[variant], badgeVariants({ className })),
67
70
  "data-variant": variant,
68
71
  "data-intent": intent,
72
+ "data-brand": brand,
69
73
  };
70
74
  }
71
75
 
72
76
  export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> & {
73
77
  variant?: BadgeVariant | undefined;
74
78
  intent?: BadgeIntent | undefined;
79
+ /** Name of a brand the theme defines, rendered as `data-brand`. Omitted, the badge follows the nearest `data-brand` ancestor. */
80
+ brand?: string | undefined;
75
81
  };
76
82
 
77
83
  /**
@@ -89,8 +95,8 @@ export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> & {
89
95
  * same seam Button uses, so a consumer can re-point a cell without a
90
96
  * release.
91
97
  */
92
- export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(({ className, variant = "solid", intent = "brand", ...props }, ref) => {
93
- return <span ref={ref} {...badgeRecipe({ variant, intent, className })} {...props} />;
98
+ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(({ className, variant = "solid", intent = "brand", brand, ...props }, ref) => {
99
+ return <span ref={ref} {...badgeRecipe({ variant, intent, brand, className })} {...props} />;
94
100
  });
95
101
  Badge.displayName = "Badge";
96
102
 
@@ -86,18 +86,23 @@ export type ButtonIntent = Loose<ButtonIntentName>;
86
86
  export type ButtonRecipeProps = VariantProps<typeof buttonVariants> & {
87
87
  variant?: ButtonVariant | undefined;
88
88
  intent?: ButtonIntent | undefined;
89
+ /** Re-points this element's brand tokens to a brand the theme defines. See the Scoped brands docs. */
90
+ brand?: string | undefined;
89
91
  iconOnly?: boolean | undefined;
90
92
  className?: string | undefined;
91
93
  };
92
94
 
93
95
  /**
94
- * The class string plus the two attributes the recipe selects on.
96
+ * The class string plus the attributes the recipe selects on.
97
+ *
98
+ * `data-brand` is only emitted when `brand` is set: `undefined` makes React
99
+ * omit the attribute, so the element takes its brand from its surroundings.
95
100
  *
96
101
  * Shared with Toggle and ToggleGroupItem, which are built on Button's look. The
97
102
  * data attributes are half the contract now, so anything reusing that look has
98
103
  * to emit them as well or it lands on the recipe's floor instead of a cell.
99
104
  */
100
- export function buttonRecipe({ variant = "solid", intent = "neutral", size, iconOnly = false, className }: ButtonRecipeProps) {
105
+ export function buttonRecipe({ variant = "solid", intent = "neutral", brand, size, iconOnly = false, className }: ButtonRecipeProps) {
101
106
  return {
102
107
  className: cn(
103
108
  variantStructure[variant],
@@ -106,6 +111,7 @@ export function buttonRecipe({ variant = "solid", intent = "neutral", size, icon
106
111
  ),
107
112
  "data-variant": variant,
108
113
  "data-intent": intent,
114
+ "data-brand": brand,
109
115
  };
110
116
  }
111
117
 
@@ -113,6 +119,12 @@ export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
113
119
  VariantProps<typeof buttonVariants> & {
114
120
  variant?: ButtonVariant | undefined;
115
121
  intent?: ButtonIntent | undefined;
122
+ /**
123
+ * Name of a brand the theme defines, rendered as `data-brand`. Re-points
124
+ * every brand token on this element, so `intent="brand"` takes that brand's
125
+ * colour. Omitted, the button follows the nearest `data-brand` ancestor.
126
+ */
127
+ brand?: string | undefined;
116
128
  /** Square the button at its current size, for an icon with no label. */
117
129
  iconOnly?: boolean;
118
130
  asChild?: boolean;
@@ -147,15 +159,23 @@ export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
147
159
  * `aria-label`, since there is no text to name it. It is a separate prop rather
148
160
  * than a size, so `size="sm" iconOnly` is expressible.
149
161
  *
162
+ * `brand` scopes the `brand` intent to one of the theme's named brands
163
+ * (`<Button intent="brand" brand="mint">`), for a theme with more than one
164
+ * brand colour. It renders `data-brand` and nothing else; the theme's CSS gives
165
+ * the name meaning, and an unknown name falls back to the surrounding brand.
166
+ *
150
167
  * `asChild` renders the child element with Button's styling instead of a
151
168
  * `<button>`, for links that should look like buttons. `loading` shows a spinner
152
169
  * and disables the control, so it does not need `disabled` as well.
153
170
  */
154
171
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
155
- ({ className, variant = "solid", intent = "neutral", size, iconOnly = false, asChild = false, loading = false, children, disabled, ...props }, ref) => {
172
+ (
173
+ { className, variant = "solid", intent = "neutral", brand, size, iconOnly = false, asChild = false, loading = false, children, disabled, ...props },
174
+ ref
175
+ ) => {
156
176
  const Comp = asChild ? Slot : "button";
157
177
  return (
158
- <Comp {...buttonRecipe({ variant, intent, size, iconOnly, className })} ref={ref} disabled={disabled || loading} {...props}>
178
+ <Comp {...buttonRecipe({ variant, intent, brand, size, iconOnly, className })} ref={ref} disabled={disabled || loading} {...props}>
159
179
  {loading ? (
160
180
  <>
161
181
  <svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
@@ -21,6 +21,7 @@ import {
21
21
  toISODateRange,
22
22
  } from "../../date-time";
23
23
  import { PickerField } from "../../internal";
24
+ import { closestBrand } from "../../internal/brandScope";
24
25
  import { useEscapeLayer } from "../../internal/escapeLayers";
25
26
  import { CalendarView } from "../Calendar/CalendarView";
26
27
  import { isDateDisabled } from "../Calendar/calendar.hooks";
@@ -539,6 +540,8 @@ export const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((pro
539
540
  role="dialog"
540
541
  aria-label="Choose date"
541
542
  style={floatingStyles}
543
+ // Portalled out of the trigger's brand scope, so carry it across.
544
+ data-brand={closestBrand(context.elements.domReference)}
542
545
  className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
543
546
  {...getFloatingProps()}
544
547
  >
@@ -16,6 +16,7 @@ import {
16
16
  toISOInstantOrUndefined,
17
17
  } from "../../date-time";
18
18
  import { PickerField } from "../../internal";
19
+ import { closestBrand } from "../../internal/brandScope";
19
20
  import { useEscapeLayer } from "../../internal/escapeLayers";
20
21
  import { Button } from "../Button";
21
22
  import { CalendarView } from "../Calendar/CalendarView";
@@ -410,6 +411,8 @@ export const DateTimePicker = React.forwardRef<HTMLDivElement, DateTimePickerPro
410
411
  role="dialog"
411
412
  aria-label="Choose date and time"
412
413
  style={floatingStyles}
414
+ // Portalled out of the trigger's brand scope, so carry it across.
415
+ data-brand={closestBrand(context.elements.domReference)}
413
416
  className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
414
417
  {...getFloatingProps()}
415
418
  >
@@ -19,6 +19,7 @@ import {
19
19
  toISOInstantRange,
20
20
  } from "../../date-time";
21
21
  import { PickerField } from "../../internal";
22
+ import { closestBrand } from "../../internal/brandScope";
22
23
  import { useEscapeLayer } from "../../internal/escapeLayers";
23
24
  import { Button } from "../Button";
24
25
  import { CalendarView } from "../Calendar/CalendarView";
@@ -630,6 +631,8 @@ export const DateTimeRangePicker = React.forwardRef<HTMLDivElement, DateTimeRang
630
631
  role="dialog"
631
632
  aria-label="Choose date and time range"
632
633
  style={floatingStyles}
634
+ // Portalled out of the trigger's brand scope, so carry it across.
635
+ data-brand={closestBrand(context.elements.domReference)}
633
636
  className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
634
637
  {...getFloatingProps()}
635
638
  >
@@ -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
  };
@@ -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)",
@@ -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",
@@ -8,6 +8,7 @@ import * as React from "react";
8
8
  import { useCallback, useId, useMemo, useState } from "react";
9
9
  import { fromISOTime, fromISOTimeOrUndefined, type Temporal, toISOTimeOrUndefined } from "../../date-time";
10
10
  import { PickerField } from "../../internal";
11
+ import { closestBrand } from "../../internal/brandScope";
11
12
  import { useEscapeLayer } from "../../internal/escapeLayers";
12
13
  import { Button } from "../Button";
13
14
  import { Separator } from "../Separator";
@@ -354,6 +355,8 @@ export const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>((pro
354
355
  role="dialog"
355
356
  aria-label="Choose time"
356
357
  style={floatingStyles}
358
+ // Portalled out of the trigger's brand scope, so carry it across.
359
+ data-brand={closestBrand(context.elements.domReference)}
357
360
  className="z-popover rounded-xl border border-border-normal bg-bg-normal p-4 shadow-lg"
358
361
  {...getFloatingProps()}
359
362
  >
@@ -68,7 +68,7 @@ export type ToastProps = Omit<MessageProps, "size"> & {
68
68
  * a dialog.
69
69
  */
70
70
  export const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
71
- ({ className, intent = "neutral", title, icon, action, onAction, onClose, root, open = true, standalone = true, children, ...props }, ref) => {
71
+ ({ className, intent = "neutral", brand, title, icon, action, onAction, onClose, root, open = true, standalone = true, children, ...props }, ref) => {
72
72
  const { ref: presenceRef, state, isMounted, show, hide } = usePresence();
73
73
 
74
74
  // `open` drives presence: enter on mount, exit when it flips false. The
@@ -118,6 +118,10 @@ export const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
118
118
  isExiting && "animate-fade-out",
119
119
  className
120
120
  )}
121
+ // On the wrapper rather than the Message, so the dismiss and action
122
+ // buttons are in the same scope. A toast has no trigger to inherit
123
+ // from: it is opened from code, often long after the click.
124
+ data-brand={brand}
121
125
  {...props}
122
126
  >
123
127
  <Message intent={intent}>
@@ -15,6 +15,8 @@ export type ToggleProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "o
15
15
  VariantProps<typeof buttonVariants> & {
16
16
  variant?: ButtonVariant | undefined;
17
17
  intent?: ButtonIntent | undefined;
18
+ /** Name of a brand the theme defines, rendered as `data-brand`. Omitted, the toggle follows the nearest `data-brand` ancestor. */
19
+ brand?: string | undefined;
18
20
  /** Square the toggle at its current size, for an icon with no label. */
19
21
  iconOnly?: boolean;
20
22
  /** Controlled pressed state */
@@ -43,7 +45,20 @@ export type ToggleProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "o
43
45
  */
44
46
  const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
45
47
  (
46
- { className, variant, intent, size, iconOnly = false, pressed: controlledPressed, defaultPressed = false, onPressedChange, disabled, children, ...props },
48
+ {
49
+ className,
50
+ variant,
51
+ intent,
52
+ brand,
53
+ size,
54
+ iconOnly = false,
55
+ pressed: controlledPressed,
56
+ defaultPressed = false,
57
+ onPressedChange,
58
+ disabled,
59
+ children,
60
+ ...props
61
+ },
47
62
  ref
48
63
  ) => {
49
64
  const [pressed, setPressed] = useControllableState({
@@ -52,7 +67,7 @@ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
52
67
  onChange: onPressedChange,
53
68
  });
54
69
 
55
- const recipe = buttonRecipe({ variant, intent, size, iconOnly });
70
+ const recipe = buttonRecipe({ variant, intent, brand, size, iconOnly });
56
71
 
57
72
  return (
58
73
  <button
@@ -17,6 +17,7 @@ const ToggleGroupMultiple = React.forwardRef<HTMLDivElement, ToggleGroupMultiple
17
17
  orientation = "horizontal",
18
18
  variant = "solid",
19
19
  intent = "neutral",
20
+ brand,
20
21
  size = "md",
21
22
  iconOnly = false,
22
23
  disabled,
@@ -39,7 +40,7 @@ const ToggleGroupMultiple = React.forwardRef<HTMLDivElement, ToggleGroupMultiple
39
40
  };
40
41
 
41
42
  return (
42
- <ToggleGroupContainer ref={ref} role="group" appearance={appearance} orientation={orientation} className={className} {...props}>
43
+ <ToggleGroupContainer ref={ref} role="group" data-brand={brand} appearance={appearance} orientation={orientation} className={className} {...props}>
43
44
  {options.map((option, index) => (
44
45
  <ToggleGroupItem
45
46
  key={option.value}