pixelize-design-library 2.4.2-beta.45 → 2.4.2-beta.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,10 +14,9 @@ next major.
14
14
 
15
15
  ### Added
16
16
 
17
- - **`SegmentedControl` / `Switch` — `surface`.** The background the control is drawn on. Unselected,
18
- selected and hover inks are re-aimed at it; without it they are measured against the page canvas,
19
- so a control on a brand-filled bar renders its inactive label at 1.0–2.0:1. Defaults to the page
20
- canvas, so existing call sites are unchanged.
17
+ - **`SegmentedControl` / `Switch` — `surface`.** The background the control is drawn on. Optional:
18
+ the track is an opaque plate on any backdrop, so the labels read without it. Pass it to aim the
19
+ plate's hairline edge at the real backdrop rather than at the page canvas.
21
20
  - **`MultiSelect` — `addOnBlur`.** Commits the typed address on focus-out as well as on Enter.
22
21
  Defaults to `false`, so existing call sites keep Enter-only behaviour.
23
22
 
@@ -64,6 +63,17 @@ next major.
64
63
 
65
64
  ### Fixed
66
65
 
66
+ - **`SegmentedControl` / `Switch` guessed the backdrop it was drawn on.** `brandSlide` painted an
67
+ 8% brand tint composited over `backgroundColor.main` and lifted its unselected-label ink against
68
+ that same assumed canvas, so on a brand-filled bar the tint sank into the bar and the label
69
+ measured **1.03–2.03:1 across all 9 brands x 2 modes** — crm's `Overview / Timeline` view switcher
70
+ on a `primary[700]` bar was unreadable. The track is now an opaque plate on every backdrop: rest
71
+ ink 7.58–18.39:1, plate-vs-bar 6.31–19.0:1, with no change required at the call site. `surface`
72
+ is now only a refinement for the plate's hairline edge, and a test gates the invariant.
73
+ - **A selected `Select` option stacked brand ink on a brand-tinted row.** Bold `accentText` on
74
+ `listRowBackgrounds().selected` read as muddy, and the row cleared the panel by only 1.16–1.29:1.
75
+ The label is now `text[900]` with the brand carried by a trailing check, matching `OrgSwitcher`.
76
+
67
77
  - **`SegmentedControl`'s pill bulged past the plate's corners.** The pill and the segments carried
68
78
  the track's own `lg` radius while sitting 2px inside it, so the plate's band stayed even along the
69
79
  straight edges but pinched to nothing at the corners. They now round concentrically at
@@ -49,27 +49,23 @@ opt-in, never assume a non-default look).
49
49
  Pick by density and emphasis, not by taste. When unsure, or migrating an existing call site, leave
50
50
  it on `"default"` — it's the only variant guaranteed to match previously-shipped screenshots.
51
51
 
52
- **On a coloured bar, pass `surface`.** `SegmentedControl` (and `Switch`, which renders it) cannot
53
- read the background its parent painted, so without `surface` it assumes the **page canvas**. Drop one
54
- into a brand-filled header and two things go wrong: `brandSlide`'s 8% tint composites into the bar,
55
- so the track stops reading as a control at all, and the inactive label — measured against the canvas
56
- — lands at **1.0–2.0:1**: technically rendered, effectively invisible.
52
+ **`brandSlide` no longer guesses its backdrop — and `surface` is now optional.** Until
53
+ 2.4.2-beta.46 the track was an 8% brand tint composited over the **page canvas**, and the inactive
54
+ label's ink was lifted against that same assumed canvas. Dropped into a brand-filled header both
55
+ went wrong at once: the tint sank into the bar, and the label landed at **1.03–2.03:1** — rendered,
56
+ effectively invisible. The track is now an opaque plate (`colors.surfaceRaised`) whatever it sits
57
+ on, so a control on a coloured bar is readable with no prop passed (rest ink 7.58–18.39:1).
57
58
 
58
- Given `surface`, the `brandSlide` track becomes an **opaque plate** (`colors.surfaceRaised`) with a
59
- 1px edge lifted to 3:1 against the bar, and every label's ink is re-aimed at that plate. The edge is
60
- what carries the boundary: in dark mode the plate and a brand bar can sit at almost the same
61
- lightness (as low as 1.56:1 on radiant), so hue alone would not separate them.
59
+ Pass `surface` only to aim the plate's 1px edge at the real backdrop instead of the page canvas.
60
+ The edge is what carries the boundary: in dark mode the plate and a brand bar can sit at almost the
61
+ same lightness (as low as 1.56:1 on radiant), so hue alone would not separate them.
62
62
 
63
63
  ```tsx
64
64
  <Box bg={theme.colors.primary[700]}>
65
- <Switch ... surface={theme.colors.primary[700]} /> // 1.24:1 -> 4.54:1
65
+ <Switch ... surface={theme.colors.primary[700]} /> // optional: sharpens the plate's edge
66
66
  </Box>
67
67
  ```
68
68
 
69
- Pass the same value you gave the wrapper's `bg`. It re-aims the unselected, selected and hover inks
70
- together, and flips direction correctly in dark mode. `HeaderActions` takes `surface` for the same
71
- reason.
72
-
73
69
  ### Which "select" component — `Select` is the only one to reach for now
74
70
 
75
71
  There is now **one** picker. `Select` does both modes off the `native` prop:
@@ -41,7 +41,9 @@ function SegmentedControl({ options, value, onChange, size = "sm", isFullWidth =
41
41
  const innerRadius = isUnderlineSlide ? "none" : `calc(${radii.lg} - ${TRACK_PADDING})`;
42
42
  const focusShadow = (0, segmentedInk_1.segmentedFocusShadow)(colors, trackSurface);
43
43
  // Inset, never a border: a border consumes the content box and the absolute pill stops centring.
44
- const plateEdge = isBrandSlide && surface ? `inset 0 0 0 1px ${(0, segmentedInk_1.segmentedPlateEdge)(colors, surface)}` : undefined;
44
+ const plateEdge = isBrandSlide
45
+ ? `inset 0 0 0 1px ${(0, segmentedInk_1.segmentedPlateEdge)(colors, surface)}`
46
+ : undefined;
45
47
  // The transparent outline replaces the UA ring and is what Windows high-contrast repaints.
46
48
  const segmentFocus = {
47
49
  outline: "2px solid transparent",
@@ -88,7 +90,7 @@ function SegmentedControl({ options, value, onChange, size = "sm", isFullWidth =
88
90
  selectAt(enabled[enabled.length - 1]);
89
91
  }
90
92
  };
91
- return ((0, jsx_runtime_1.jsxs)(react_2.Flex, { ref: trackRef, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: handleKeyDown, onFocus: (event) => setShowFocus(event.target.matches(":focus-visible")), onBlur: () => setShowFocus(false), boxShadow: showFocus && !isUnderlineSlide ? focusShadow : plateEdge, position: "relative", display: "inline-flex", align: "center", gap: isUnderlineSlide ? "0" : TRACK_PADDING, p: isUnderlineSlide ? "0" : TRACK_PADDING, h: control.height, boxSizing: "border-box", borderRadius: isUnderlineSlide ? "none" : "lg", borderBottom: isUnderlineSlide ? `${UNDERLINE_WIDTH} solid ${colors.boxborder[200]}` : undefined, bg: isBrandSlide ? (surface ? trackSurface : colors.primary.opacity[8]) : isUnderlineSlide ? "transparent" : colors.background[600], w: isFullWidth ? "100%" : "fit-content", minW: "max-content", children: [isBrandSlide && pill && ((0, jsx_runtime_1.jsx)(react_2.Box, { "aria-hidden": "true", position: "absolute", top: TRACK_PADDING, left: 0, h: segmentH, w: `${pill.width}px`, borderRadius: innerRadius, bg: brandFill.fill, boxShadow: `0 0.125rem 0.5rem ${colors.boxShadow.primary}`, transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: (0, motion_2.reducedMotionSx)() })), isUnderlineSlide && pill && ((0, jsx_runtime_1.jsx)(react_2.Box, { "aria-hidden": "true", position: "absolute", bottom: "-0.0625rem", left: 0, h: "0.125rem", w: `${pill.width}px`, borderRadius: "full", bg: colors.primary[500], transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: (0, motion_2.reducedMotionSx)() })), options.map((option, index) => {
93
+ return ((0, jsx_runtime_1.jsxs)(react_2.Flex, { ref: trackRef, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: handleKeyDown, onFocus: (event) => setShowFocus(event.target.matches(":focus-visible")), onBlur: () => setShowFocus(false), boxShadow: showFocus && !isUnderlineSlide ? focusShadow : plateEdge, position: "relative", display: "inline-flex", align: "center", gap: isUnderlineSlide ? "0" : TRACK_PADDING, p: isUnderlineSlide ? "0" : TRACK_PADDING, h: control.height, boxSizing: "border-box", borderRadius: isUnderlineSlide ? "none" : "lg", borderBottom: isUnderlineSlide ? `${UNDERLINE_WIDTH} solid ${colors.boxborder[200]}` : undefined, bg: isUnderlineSlide ? "transparent" : trackSurface, w: isFullWidth ? "100%" : "fit-content", minW: "max-content", children: [isBrandSlide && pill && ((0, jsx_runtime_1.jsx)(react_2.Box, { "aria-hidden": "true", position: "absolute", top: TRACK_PADDING, left: 0, h: segmentH, w: `${pill.width}px`, borderRadius: innerRadius, bg: brandFill.fill, boxShadow: `0 0.125rem 0.5rem ${colors.boxShadow.primary}`, transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: (0, motion_2.reducedMotionSx)() })), isUnderlineSlide && pill && ((0, jsx_runtime_1.jsx)(react_2.Box, { "aria-hidden": "true", position: "absolute", bottom: "-0.0625rem", left: 0, h: "0.125rem", w: `${pill.width}px`, borderRadius: "full", bg: colors.primary[500], transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: (0, motion_2.reducedMotionSx)() })), options.map((option, index) => {
92
94
  const isSelected = option.value === value;
93
95
  return ((0, jsx_runtime_1.jsxs)(react_2.Box, { as: "button", type: "button", ref: (node) => {
94
96
  refs.current[index] = node;
@@ -27,7 +27,7 @@ export type SegmentedControlProps<T extends string = string> = {
27
27
  isFullWidth?: boolean;
28
28
  /** brandSlide = primary view switcher; underlineSlide = dense toolbars/headers. */
29
29
  variant?: SegmentedControlVariant;
30
- /** Background this sits on. Pass it off the page canvas: brandSlide's track becomes an opaque plate with its own edge, and every label's ink is re-aimed at that plate. */
30
+ /** Background this sits on. Optional: the track is opaque on any backdrop; pass it only to aim the plate's hairline edge at the real backdrop. */
31
31
  surface?: string;
32
32
  "aria-label": string;
33
33
  };
@@ -1,11 +1,11 @@
1
1
  import type { PaletteProps } from "../../Theme/tokens/types";
2
2
  import type { SegmentedControlVariant } from "./SegmentedControlProps";
3
- /** The surface a segment's label really sits on; brandSlide's track is a translucent tint, so composite it. */
3
+ /** The surface a segment's label really sits on. */
4
4
  export declare const segmentedTrackSurface: (variant: SegmentedControlVariant, colors: PaletteProps, surface?: string) => string;
5
5
  /** Unselected label ink, lifted to AA against the track it is drawn on. */
6
6
  export declare const segmentedRestInk: (colors: PaletteProps, trackSurface: string) => string;
7
7
  /** A radiogroup is one tab stop, so the group wears the ring: a brand hairline plus the field bloom. */
8
8
  export declare const segmentedFocusShadow: (colors: PaletteProps, trackSurface: string) => string;
9
- /** On a coloured bar the plate can match the bar's lightness, so its edge carries the boundary.
9
+ /** The plate can match its backdrop's lightness, so its edge carries the boundary.
10
10
  * Drawn inset by the caller: a real border consumes the content box and decentres the sliding pill. */
11
- export declare const segmentedPlateEdge: (colors: PaletteProps, surface: string) => string;
11
+ export declare const segmentedPlateEdge: (colors: PaletteProps, surface?: string) => string;
@@ -2,19 +2,16 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.segmentedPlateEdge = exports.segmentedFocusShadow = exports.segmentedRestInk = exports.segmentedTrackSurface = void 0;
4
4
  const focusRing_styles_1 = require("../../Theme/chakra/focusRing.styles");
5
- const color_1 = require("../../Theme/tokens/builders/color");
6
5
  const outlineRung_1 = require("../../Theme/tokens/builders/outlineRung");
7
6
  const accentText_1 = require("../../Theme/tokens/builders/accentText");
8
- /** The surface a segment's label really sits on; brandSlide's track is a translucent tint, so composite it. */
7
+ /** The surface a segment's label really sits on. */
9
8
  const segmentedTrackSurface = (variant, colors, surface) => {
10
- const canvas = surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main;
11
9
  if (variant === "underlineSlide")
12
- return canvas;
13
- // An 8% tint reads on the page canvas; on a coloured bar it composites into the bar and the
14
- // control stops looking like a control, so there the track becomes an opaque plate.
15
- if (variant === "brandSlide") {
16
- return surface ? colors.surfaceRaised : (0, color_1.compositeOver)(colors.primary.opacity[8], canvas);
17
- }
10
+ return surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main;
11
+ // brandSlide paints an opaque plate on every backdrop: a tint composites into an unknown
12
+ // parent, so its ink was derived from a canvas the control was not actually on.
13
+ if (variant === "brandSlide")
14
+ return colors.surfaceRaised;
18
15
  return colors.background[600];
19
16
  };
20
17
  exports.segmentedTrackSurface = segmentedTrackSurface;
@@ -24,7 +21,7 @@ exports.segmentedRestInk = segmentedRestInk;
24
21
  /** A radiogroup is one tab stop, so the group wears the ring: a brand hairline plus the field bloom. */
25
22
  const segmentedFocusShadow = (colors, trackSurface) => `inset 0 0 0 1px ${(0, outlineRung_1.liftToContrast)(colors.primary[500], trackSurface, outlineRung_1.AA_NON_TEXT)}, ${(0, focusRing_styles_1.focusGlowShadow)(colors)}`;
26
23
  exports.segmentedFocusShadow = segmentedFocusShadow;
27
- /** On a coloured bar the plate can match the bar's lightness, so its edge carries the boundary.
24
+ /** The plate can match its backdrop's lightness, so its edge carries the boundary.
28
25
  * Drawn inset by the caller: a real border consumes the content box and decentres the sliding pill. */
29
- const segmentedPlateEdge = (colors, surface) => (0, outlineRung_1.liftToContrast)(colors.boxborder[500], surface, outlineRung_1.AA_NON_TEXT);
26
+ const segmentedPlateEdge = (colors, surface) => (0, outlineRung_1.liftToContrast)(colors.boxborder[500], surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main, outlineRung_1.AA_NON_TEXT);
30
27
  exports.segmentedPlateEdge = segmentedPlateEdge;
@@ -43,6 +43,7 @@ const lucide_react_1 = require("lucide-react");
43
43
  const useCustomTheme_1 = require("../../Theme/useCustomTheme");
44
44
  const focusRing_styles_1 = require("../../Theme/chakra/focusRing.styles");
45
45
  const rowStates_1 = require("../../Theme/tokens/builders/rowStates");
46
+ const optionRowInk_1 = require("./optionRowInk");
46
47
  const scrollbar_1 = require("../../Theme/tokens/builders/scrollbar");
47
48
  const controlSizes_1 = require("../../Theme/tokens/primitives/controlSizes");
48
49
  const motion_1 = require("../../Theme/tokens/primitives/motion");
@@ -80,7 +81,7 @@ const OptionRow = react_1.default.memo(function OptionRow({ option, index, listb
80
81
  const isSingleSelected = isSelected && !isMulti;
81
82
  const selectedBg = isActive ? backgrounds.selectedActive : backgrounds.selected;
82
83
  const restBg = isActive ? backgrounds.active : "transparent";
83
- return ((0, jsx_runtime_1.jsxs)(react_2.Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSingleSelected ? 600 : 400, color: isSingleSelected ? theme.colors.accentText : theme.colors.text[700], bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: (0, motion_2.reducedMotionSx)(), children: [isMulti ? ((0, jsx_runtime_1.jsx)(react_2.Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? ((0, jsx_runtime_1.jsx)(react_2.Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [option.icon ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, (0, jsx_runtime_1.jsxs)(react_2.Box, { flex: 1, minW: 0, isTruncated: true, children: [(0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", isTruncated: true, children: option.label }), option.description ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "block", fontSize: "0.75rem", color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] }))] }));
84
+ return ((0, jsx_runtime_1.jsxs)(react_2.Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSingleSelected ? 600 : 400, color: (0, optionRowInk_1.optionRowInk)(theme.colors, isSingleSelected), bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: (0, motion_2.reducedMotionSx)(), children: [isMulti ? ((0, jsx_runtime_1.jsx)(react_2.Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? ((0, jsx_runtime_1.jsx)(react_2.Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [option.icon ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, (0, jsx_runtime_1.jsxs)(react_2.Box, { flex: 1, minW: 0, isTruncated: true, children: [(0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", isTruncated: true, children: option.label }), option.description ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "block", fontSize: "0.75rem", color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] })), isSingleSelected ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: lucide_react_1.Check, size: 16, color: theme.colors.accentText, flexShrink: 0 })) : null] }));
84
85
  });
85
86
  const buildGroupedRows = (options) => {
86
87
  const rows = [];
@@ -0,0 +1,3 @@
1
+ import type { PaletteProps } from "../../Theme/tokens/types";
2
+ /** Selection is carried by weight and a trailing brand check, so the label itself stays neutral. */
3
+ export declare const optionRowInk: (colors: PaletteProps, isSingleSelected: boolean) => string;
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.optionRowInk = void 0;
4
+ /** Selection is carried by weight and a trailing brand check, so the label itself stays neutral. */
5
+ const optionRowInk = (colors, isSingleSelected) => isSingleSelected ? colors.text[900] : colors.text[700];
6
+ exports.optionRowInk = optionRowInk;
@@ -8,7 +8,7 @@ export type SwitchProps = {
8
8
  onSwitchToggle: (value: "left" | "right") => void;
9
9
  /** Controlled selected side; the pill only moves when this changes. */
10
10
  switchActive: "left" | "right";
11
- /** Background this sits on. Pass it off the page canvas — on a brand-filled bar the track sinks into the bar and the inactive label is unreadable without it. */
11
+ /** Background this sits on. Optional: the track is opaque on any backdrop; pass it only to aim the plate's hairline edge at the real backdrop. */
12
12
  surface?: string;
13
13
  /** Names the radiogroup; pass it, as there is no visible group label. */
14
14
  "aria-label"?: string;
@@ -35,7 +35,9 @@ export default function SegmentedControl({ options, value, onChange, size = "sm"
35
35
  const innerRadius = isUnderlineSlide ? "none" : `calc(${radii.lg} - ${TRACK_PADDING})`;
36
36
  const focusShadow = segmentedFocusShadow(colors, trackSurface);
37
37
  // Inset, never a border: a border consumes the content box and the absolute pill stops centring.
38
- const plateEdge = isBrandSlide && surface ? `inset 0 0 0 1px ${segmentedPlateEdge(colors, surface)}` : undefined;
38
+ const plateEdge = isBrandSlide
39
+ ? `inset 0 0 0 1px ${segmentedPlateEdge(colors, surface)}`
40
+ : undefined;
39
41
  // The transparent outline replaces the UA ring and is what Windows high-contrast repaints.
40
42
  const segmentFocus = {
41
43
  outline: "2px solid transparent",
@@ -82,7 +84,7 @@ export default function SegmentedControl({ options, value, onChange, size = "sm"
82
84
  selectAt(enabled[enabled.length - 1]);
83
85
  }
84
86
  };
85
- return (_jsxs(Flex, { ref: trackRef, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: handleKeyDown, onFocus: (event) => setShowFocus(event.target.matches(":focus-visible")), onBlur: () => setShowFocus(false), boxShadow: showFocus && !isUnderlineSlide ? focusShadow : plateEdge, position: "relative", display: "inline-flex", align: "center", gap: isUnderlineSlide ? "0" : TRACK_PADDING, p: isUnderlineSlide ? "0" : TRACK_PADDING, h: control.height, boxSizing: "border-box", borderRadius: isUnderlineSlide ? "none" : "lg", borderBottom: isUnderlineSlide ? `${UNDERLINE_WIDTH} solid ${colors.boxborder[200]}` : undefined, bg: isBrandSlide ? (surface ? trackSurface : colors.primary.opacity[8]) : isUnderlineSlide ? "transparent" : colors.background[600], w: isFullWidth ? "100%" : "fit-content", minW: "max-content", children: [isBrandSlide && pill && (_jsx(Box, { "aria-hidden": "true", position: "absolute", top: TRACK_PADDING, left: 0, h: segmentH, w: `${pill.width}px`, borderRadius: innerRadius, bg: brandFill.fill, boxShadow: `0 0.125rem 0.5rem ${colors.boxShadow.primary}`, transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: reducedMotionSx() })), isUnderlineSlide && pill && (_jsx(Box, { "aria-hidden": "true", position: "absolute", bottom: "-0.0625rem", left: 0, h: "0.125rem", w: `${pill.width}px`, borderRadius: "full", bg: colors.primary[500], transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: reducedMotionSx() })), options.map((option, index) => {
87
+ return (_jsxs(Flex, { ref: trackRef, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: handleKeyDown, onFocus: (event) => setShowFocus(event.target.matches(":focus-visible")), onBlur: () => setShowFocus(false), boxShadow: showFocus && !isUnderlineSlide ? focusShadow : plateEdge, position: "relative", display: "inline-flex", align: "center", gap: isUnderlineSlide ? "0" : TRACK_PADDING, p: isUnderlineSlide ? "0" : TRACK_PADDING, h: control.height, boxSizing: "border-box", borderRadius: isUnderlineSlide ? "none" : "lg", borderBottom: isUnderlineSlide ? `${UNDERLINE_WIDTH} solid ${colors.boxborder[200]}` : undefined, bg: isUnderlineSlide ? "transparent" : trackSurface, w: isFullWidth ? "100%" : "fit-content", minW: "max-content", children: [isBrandSlide && pill && (_jsx(Box, { "aria-hidden": "true", position: "absolute", top: TRACK_PADDING, left: 0, h: segmentH, w: `${pill.width}px`, borderRadius: innerRadius, bg: brandFill.fill, boxShadow: `0 0.125rem 0.5rem ${colors.boxShadow.primary}`, transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: reducedMotionSx() })), isUnderlineSlide && pill && (_jsx(Box, { "aria-hidden": "true", position: "absolute", bottom: "-0.0625rem", left: 0, h: "0.125rem", w: `${pill.width}px`, borderRadius: "full", bg: colors.primary[500], transform: `translateX(${pill.left}px)`, transition: SLIDE, sx: reducedMotionSx() })), options.map((option, index) => {
86
88
  const isSelected = option.value === value;
87
89
  return (_jsxs(Box, { as: "button", type: "button", ref: (node) => {
88
90
  refs.current[index] = node;
@@ -1,23 +1,20 @@
1
1
  import { focusGlowShadow } from "../../Theme/chakra/focusRing.styles.js";
2
- import { compositeOver } from "../../Theme/tokens/builders/color.js";
3
2
  import { AA_NON_TEXT, liftToContrast } from "../../Theme/tokens/builders/outlineRung.js";
4
3
  import { AA_NORMAL_TEXT } from "../../Theme/tokens/builders/accentText.js";
5
- /** The surface a segment's label really sits on; brandSlide's track is a translucent tint, so composite it. */
4
+ /** The surface a segment's label really sits on. */
6
5
  export const segmentedTrackSurface = (variant, colors, surface) => {
7
- const canvas = surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main;
8
6
  if (variant === "underlineSlide")
9
- return canvas;
10
- // An 8% tint reads on the page canvas; on a coloured bar it composites into the bar and the
11
- // control stops looking like a control, so there the track becomes an opaque plate.
12
- if (variant === "brandSlide") {
13
- return surface ? colors.surfaceRaised : compositeOver(colors.primary.opacity[8], canvas);
14
- }
7
+ return surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main;
8
+ // brandSlide paints an opaque plate on every backdrop: a tint composites into an unknown
9
+ // parent, so its ink was derived from a canvas the control was not actually on.
10
+ if (variant === "brandSlide")
11
+ return colors.surfaceRaised;
15
12
  return colors.background[600];
16
13
  };
17
14
  /** Unselected label ink, lifted to AA against the track it is drawn on. */
18
15
  export const segmentedRestInk = (colors, trackSurface) => liftToContrast(colors.text[600], trackSurface, AA_NORMAL_TEXT);
19
16
  /** A radiogroup is one tab stop, so the group wears the ring: a brand hairline plus the field bloom. */
20
17
  export const segmentedFocusShadow = (colors, trackSurface) => `inset 0 0 0 1px ${liftToContrast(colors.primary[500], trackSurface, AA_NON_TEXT)}, ${focusGlowShadow(colors)}`;
21
- /** On a coloured bar the plate can match the bar's lightness, so its edge carries the boundary.
18
+ /** The plate can match its backdrop's lightness, so its edge carries the boundary.
22
19
  * Drawn inset by the caller: a real border consumes the content box and decentres the sliding pill. */
23
- export const segmentedPlateEdge = (colors, surface) => liftToContrast(colors.boxborder[500], surface, AA_NON_TEXT);
20
+ export const segmentedPlateEdge = (colors, surface) => liftToContrast(colors.boxborder[500], surface !== null && surface !== void 0 ? surface : colors.backgroundColor.main, AA_NON_TEXT);
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Checkbox, FormControl, Input, Popover, PopoverAnchor, PopoverContent, Portal, Spinner, useDisclosure, useMergeRefs, } from "@chakra-ui/react";
4
- import { ChevronDown, X } from "lucide-react";
4
+ import { Check, ChevronDown, X } from "lucide-react";
5
5
  import { useCustomTheme } from "../../Theme/useCustomTheme.js";
6
6
  import { focusGlowShadow } from "../../Theme/chakra/focusRing.styles.js";
7
7
  import { listRowBackgrounds } from "../../Theme/tokens/builders/rowStates.js";
8
+ import { optionRowInk } from "./optionRowInk.js";
8
9
  import { scrollbarStyles } from "../../Theme/tokens/builders/scrollbar.js";
9
10
  import { CONTROL_SIZES, DEFAULT_CONTROL_SIZE } from "../../Theme/tokens/primitives/controlSizes.js";
10
11
  import { transitionFor } from "../../Theme/tokens/primitives/motion.js";
@@ -42,7 +43,7 @@ const OptionRow = React.memo(function OptionRow({ option, index, listboxId, isSe
42
43
  const isSingleSelected = isSelected && !isMulti;
43
44
  const selectedBg = isActive ? backgrounds.selectedActive : backgrounds.selected;
44
45
  const restBg = isActive ? backgrounds.active : "transparent";
45
- return (_jsxs(Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSingleSelected ? 600 : 400, color: isSingleSelected ? theme.colors.accentText : theme.colors.text[700], bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: reducedMotionSx(), children: [isMulti ? (_jsx(Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? (_jsx(Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : (_jsxs(_Fragment, { children: [option.icon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, _jsxs(Box, { flex: 1, minW: 0, isTruncated: true, children: [_jsx(Box, { as: "span", isTruncated: true, children: option.label }), option.description ? (_jsx(Box, { as: "span", display: "block", fontSize: "0.75rem", color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] }))] }));
46
+ return (_jsxs(Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSingleSelected ? 600 : 400, color: optionRowInk(theme.colors, isSingleSelected), bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: reducedMotionSx(), children: [isMulti ? (_jsx(Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? (_jsx(Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : (_jsxs(_Fragment, { children: [option.icon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, _jsxs(Box, { flex: 1, minW: 0, isTruncated: true, children: [_jsx(Box, { as: "span", isTruncated: true, children: option.label }), option.description ? (_jsx(Box, { as: "span", display: "block", fontSize: "0.75rem", color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] })), isSingleSelected ? (_jsx(Box, { as: Check, size: 16, color: theme.colors.accentText, flexShrink: 0 })) : null] }));
46
47
  });
47
48
  const buildGroupedRows = (options) => {
48
49
  const rows = [];
@@ -0,0 +1,2 @@
1
+ /** Selection is carried by weight and a trailing brand check, so the label itself stays neutral. */
2
+ export const optionRowInk = (colors, isSingleSelected) => isSingleSelected ? colors.text[900] : colors.text[700];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixelize-design-library",
3
- "version": "2.4.2-beta.45",
3
+ "version": "2.4.2-beta.46",
4
4
  "description": "React component library for Pixelize apps: themeable Chakra-based components, design tokens and light/dark brand theming.",
5
5
  "keywords": [
6
6
  "react",