@stackone/malachite 2.43.0 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COMPONENTS.md CHANGED
@@ -83,7 +83,7 @@ The catalog of components exported from `@stackone/malachite`. **Reach for one o
83
83
  - **`Search`** — Search input with a leading icon; `filled` and `muted` variants. `width` fixes the field's width, the same prop `Dropdown` and `MultiSelect` take, so a toolbar can put its controls on one width rather than letting each size itself; without it the field fills its container, and a container narrower than the value still wins. _Key props:_ `value`, `onChange`, `placeholder`, `variant`, `size`, `width`, `disabled`.
84
84
  - **`Checkbox`** — Checkbox supporting checked/indeterminate/disabled. Pass `presentational` to render a visual-only, non-focusable indicator for use inside a clickable row/card. _Key props:_ `checked`, `indeterminate`, `disabled`, `onChange`, `presentational`.
85
85
  - **`Radio`** — Single radio control with hover state; parent manages the group. Pass `presentational` to render a visual-only, non-focusable indicator for use inside a clickable row/card. _Key props:_ `checked`, `onChange`, `disabled`, `size`, `presentational`.
86
- - **`SegmentedControl`** — A rounded track of mutually-exclusive options — the segmented “one of these” control. Owns the track background, the selected-thumb chrome and the `radiogroup` semantics, so consumers never hand-roll a track around `PillButton` (whose `default` variant tokens are tuned for standalone filter pills, not a sunken track). Single tab stop with arrow-key move-and-select; selection is conveyed via `aria-checked` and a foreground change, not background colour alone. Restyle via the `segmentedControl` theme tokens. _Key props:_ `options` (`value`, `label`, `icon`, `disabled`), `value`, `onChange`, `SegmentedControlSize` (`small` | `medium` | `large`), `fullWidth`, `disabled`, `aria-label`, `aria-labelledby`, `className`.
86
+ - **`SegmentedControl`** — A rounded track of mutually-exclusive options — the segmented “one of these” control. Owns the track background, the selected-thumb chrome and the `radiogroup` semantics, so consumers never hand-roll a track around `PillButton` (whose `default` variant tokens are tuned for standalone filter pills, not a sunken track). Single tab stop with arrow-key move-and-select; selection is conveyed via `aria-checked` and a foreground change, not background colour alone. An option `icon` sits before the label in a fixed 16px slot, and an option `badge` follows the label at whatever width it needs, for a mark such as a compact `Tag` naming a saving; it renders inside the segment button, so it must not be interactive. Restyle via the `segmentedControl` theme tokens. _Key props:_ `options` (`value`, `label`, `icon`, `badge`, `disabled`), `value`, `onChange`, `SegmentedControlSize` (`small` | `medium` | `large`), `fullWidth`, `disabled`, `aria-label`, `aria-labelledby`, `className`.
87
87
  - **`Toggle`** — On/off switch. Add `label` for a visible caption, and `variant` to give it standalone toolbar-filter chrome — pick the family it sits beside: `pill` matches the filter-bar inputs (`Search`, `MultiSelect`), `button` renders the whole control as an outline `Button` with the switch as its trailing icon. `bordered` is shorthand for `variant="pill"`. `tone="pending"` paints the checked track amber, for a switch whose change has not been written yet — pair it with a `SelectionBar` reporting the same diff. The unchecked track never changes: nothing is pending about a switch that is off. Forwards a ref to its root element, so it can anchor an `InlineConfirm`. _Key props:_ `checked`, `onChange`, `disabled`, `ToggleSize` (`small` | `medium`), `ToggleTone` (`default` | `pending`), `label`, `ToggleVariant` (`pill` | `button`), `bordered`, `aria-label`, `aria-labelledby`, `aria-describedby`, `id`.
88
88
  - **`Dropdown`** — Single-select dropdown. `DropdownSize` is `small` (28px) | `medium` (32px) | `large` (42px), matching Button; `fullWidth` fills the container instead of sizing to content, which is what a table cell or a toolbar slot needs — without it the field column sizes to its content, so a consumer has to compute a width; `width` fixes that width outright, the way `MultiSelect` does, for a toolbar lining the two up rather than letting each hug its own label — a narrower container still wins, since the width clamps to it; Until this release a `search` trigger could not go below the intrinsic width of its bare `input`, so a `width` under roughly 200px was ignored; it now shrinks with the rest. The open menu fills the trigger and left-aligns under it, as Figma draws every tier of `Dropdown Component - Standard`, so `width` sizes both — the menu is the width of the control that opened it and does not widen for a long option, which ellipsises instead, so a row of controls and their open menus stay on one set of edges; below 160px the menu keeps that floor rather than shrinking to a chip nobody can read, and `minWidth` raises that floor without widening the trigger — for a picker a column pins narrow whose options carry a subtitle the trigger never shows; near a viewport edge it anchors to the trigger's other side before falling back to the viewport, so the two share an edge wherever one fits; `small` is the chip that sits beside a page title and rounds tighter (10px) than the taller sizes. These names each moved up a tier in 0.71 — the old `xsmall`/`small`/`medium` are now `small`/`medium`/`large`. Pass `value` for a controlled selection whose source of truth lives elsewhere (URL or app state): the trigger follows it without the consumer remounting via `key`, which used to discard focus mid-interaction. `defaultValue` keeps the uncontrolled behaviour. `clearable` adds a clear control to the trigger once something is selected and reports it as `onItemSelected(undefined)` — without it a selection cannot be undone, so filter bars had to invent a sentinel "any" item to carry that meaning. `requiredIndicator` switches the label between the library default of naming the optional fields, an `asterisk` on the required ones for designs drawn that way, and `none` for a field whose value is supplied rather than asked for — a locked control is still required, but a marker only sends the reader looking for something to fill in. Visual only: `aria-required` is set from `required` whichever is chosen. `showSubtitleInTrigger` promotes the selected item's `subtitle` into the closed trigger, on one line under the value, so the consequence of a choice stays on screen once the menu shuts — the trigger trades its fixed height for a floor and grows by that line, and either line too long for it ellipsises, with a single tooltip carrying whichever lines are cut off, value first. It does not combine with `search`, whose trigger is a single-line text input with nowhere to put a second line, so the type rejects the pair rather than letting a consumer ask for a subtitle and get none. `subtitleLayout` decides how a subtitle sits in the open menu: the default `inline` keeps it on the label's row after a divider, which suits a short qualifier, while `stacked` gives it its own line beneath the label — one line either way, ellipsised rather than wrapped, so inline a sentence-length subtitle is cut back to a few words. Every option row carries a tooltip for whatever it had to cut, across all three of label, subtitle and `rightSideText`, so nothing the row hides is unreadable. Passing `formControl` is enough to show validation on the same terms as `Input`, though only validation: the trigger still paints `value` or `defaultValue`, never the value the form holds. The resolver's message renders on its own line beneath the field in the destructive colour, politely announced, with `aria-invalid` and `aria-describedby` wired, while an explicit `helperText` keeps its own line above it and stays dimmed, and `error={false}` suppresses the resolver outright. An item's `subtitle` renders inline markdown in the option, trigger and tooltip, with links drawn but inert, since the option is itself the click target; a subtitle with no markdown syntax stays a plain text node. _Key props:_ `DropdownItem[]`, `DropdownSize`, `fullWidth`, `width`, `value`, `defaultValue`, `label`, `search`, `showSubtitleInTrigger`, `subtitleLayout`, `clearable`, `required`, `requiredIndicator`, `error`, `helperText`, `loading`, `formControl`, `aria-label`, `aria-labelledby`, `id`.
89
89
  - **`MultiSelect`** — Multi-select dropdown. `width` fixes the trigger, as `Dropdown` and `Search` take it, so a toolbar can put its controls on one width — and, as with those two, a container narrower than the value still wins; the menu fills the trigger and left-aligns under it, so that one prop sizes both; `minWidth` raises the 160px floor a narrow trigger falls back to. An option label too long for the menu ellipsises, and hovering it shows the full label — above the description in `hover` mode — as `Dropdown` does for its options. `requiredIndicator` follows the same three-way convention the single-select controls do, so a form choosing asterisks does not get "(Optional)" on one of its fields. _Key props:_ `MultiSelectOption[]`, `MultiSelectSize`, `width`, `minWidth`, `label`, `searchable`, `inlineSearch`, `chips`, `chipsRemovable`, `required`, `requiredIndicator`, `aria-label`, `aria-labelledby`, `id`.
@@ -1,7 +1,7 @@
1
- import { jsx as g, jsxs as I } from "react/jsx-runtime";
1
+ import { jsx as u, jsxs as I } from "react/jsx-runtime";
2
2
  import { useRef as $, useCallback as A } from "react";
3
3
  import { FOCUS_RING as E } from "./focusRing.js";
4
- const D = {
4
+ const N = {
5
5
  small: {
6
6
  text: "text-[length:var(--malachite-font-size-small,13px)]",
7
7
  track: "h-[var(--malachite-control-height-small,28px)]",
@@ -17,81 +17,83 @@ const D = {
17
17
  track: "h-[var(--malachite-control-height-large,42px)]",
18
18
  item: "h-full px-4"
19
19
  }
20
- }, N = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]), j = "inline-flex items-center justify-center gap-1.5 rounded-[var(--malachite-radius-10,10px)] border-0 font-medium tracking-[0.13px] whitespace-nowrap transition-colors duration-150 ease-in-out", K = ({
21
- options: n,
22
- value: s,
20
+ }, D = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]), j = "inline-flex items-center justify-center gap-1.5 rounded-[var(--malachite-radius-10,10px)] border-0 font-medium tracking-[0.13px] whitespace-nowrap transition-colors duration-150 ease-in-out", B = ({
21
+ options: s,
22
+ value: o,
23
23
  onChange: d,
24
24
  size: b = "medium",
25
- fullWidth: u = !1,
26
- disabled: o = !1,
25
+ fullWidth: x = !1,
26
+ disabled: c = !1,
27
27
  "aria-label": p,
28
28
  "aria-labelledby": k,
29
29
  className: v = ""
30
30
  }) => {
31
- const x = $([]), m = D[b], f = n.findIndex((e) => e.value === s), w = f >= 0 && !n[f].disabled ? f : n.findIndex((e) => !e.disabled), y = A(
31
+ const g = $([]), m = N[b], h = s.findIndex((e) => e.value === o), w = h >= 0 && !s[h].disabled ? h : s.findIndex((e) => !e.disabled), y = A(
32
32
  (e) => {
33
- var c;
34
- if (o || !N.has(e.key))
33
+ var n;
34
+ if (c || !D.has(e.key))
35
35
  return;
36
- const t = n.reduce((i, h, S) => (h.disabled || i.push({ option: h, index: S }), i), []);
36
+ const t = s.reduce((l, f, S) => (f.disabled || l.push({ option: f, index: S }), l), []);
37
37
  if (t.length === 0)
38
38
  return;
39
39
  e.preventDefault();
40
- const l = t.findIndex(({ option: i }) => i.value === s);
41
- let r;
40
+ const r = t.findIndex(({ option: l }) => l.value === o);
41
+ let a;
42
42
  if (e.key === "Home")
43
- r = 0;
43
+ a = 0;
44
44
  else if (e.key === "End")
45
- r = t.length - 1;
45
+ a = t.length - 1;
46
46
  else {
47
- const i = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 1;
48
- r = ((l === -1 ? 0 : l) + i + t.length) % t.length;
47
+ const l = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 1;
48
+ a = ((r === -1 ? 0 : r) + l + t.length) % t.length;
49
49
  }
50
- const a = t[r];
51
- a.option.value !== s && d(a.option.value), (c = x.current[a.index]) == null || c.focus();
50
+ const i = t[a];
51
+ i.option.value !== o && d(i.option.value), (n = g.current[i.index]) == null || n.focus();
52
52
  },
53
- [o, d, n, s]
53
+ [c, d, s, o]
54
54
  );
55
- return /* @__PURE__ */ g(
55
+ return /* @__PURE__ */ u(
56
56
  "div",
57
57
  {
58
58
  role: "radiogroup",
59
59
  "aria-label": p,
60
60
  "aria-labelledby": k,
61
- "aria-disabled": o || void 0,
61
+ "aria-disabled": c || void 0,
62
62
  onKeyDown: y,
63
63
  className: `${// One display utility, not two: `flex` and `inline-flex` are both plain
64
64
  // `display` utilities, so emitting both would resolve by stylesheet order
65
65
  // (same hazard as the background above).
66
- u ? "flex w-full" : "inline-flex"} ${m.track} items-center gap-1 p-0.5 rounded-xl bg-[var(--malachite-segmented-control-track-background)] ${o ? "opacity-50" : ""} ${v}`,
67
- children: n.map((e, t) => {
68
- const l = e.value === s, r = o || e.disabled;
69
- let a;
70
- return l ? a = "bg-[var(--malachite-segmented-control-selected-background)] text-[var(--malachite-segmented-control-selected-foreground)]" : r ? a = "bg-transparent text-[var(--malachite-segmented-control-item-foreground)]" : a = "bg-transparent text-[var(--malachite-segmented-control-item-foreground)] hover:text-[var(--malachite-segmented-control-item-hover-foreground)] cursor-pointer", /* @__PURE__ */ I(
66
+ x ? "flex w-full" : "inline-flex"} ${m.track} items-center gap-1 p-0.5 rounded-xl bg-[var(--malachite-segmented-control-track-background)] ${c ? "opacity-50" : ""} ${v}`,
67
+ children: s.map((e, t) => {
68
+ const r = e.value === o, a = c || e.disabled, i = e.badge !== void 0 && e.badge !== null && e.badge !== !1;
69
+ let n;
70
+ return r ? n = "bg-[var(--malachite-segmented-control-selected-background)] text-[var(--malachite-segmented-control-selected-foreground)]" : a ? n = "bg-transparent text-[var(--malachite-segmented-control-item-foreground)]" : n = "bg-transparent text-[var(--malachite-segmented-control-item-foreground)] hover:text-[var(--malachite-segmented-control-item-hover-foreground)] cursor-pointer", /* @__PURE__ */ I(
71
71
  "button",
72
72
  {
73
- ref: (c) => {
74
- x.current[t] = c;
73
+ ref: (l) => {
74
+ g.current[t] = l;
75
75
  },
76
76
  type: "button",
77
77
  role: "radio",
78
- "aria-checked": l,
79
- disabled: r,
78
+ "aria-checked": r,
79
+ disabled: a,
80
80
  tabIndex: t === w ? 0 : -1,
81
81
  onClick: () => {
82
- !r && !l && d(e.value);
82
+ !a && !r && d(e.value);
83
83
  },
84
- className: `${E} ${j} ${m.text} ${m.item} ${a} ${u ? "flex-1" : ""} ${e.disabled && !o ? "opacity-50 cursor-not-allowed" : ""}`,
84
+ className: `${E} ${j} ${m.text} ${m.item} ${n} ${x ? "flex-1" : ""} ${e.disabled && !c ? "opacity-50 cursor-not-allowed" : ""}`,
85
85
  style: {
86
86
  // Inline rather than a `shadow-[...]` class: Tailwind's shadow
87
87
  // utilities compose `--tw-shadow-*` custom properties that rely on
88
88
  // document-level @property registration and silently don't paint
89
89
  // inside a shadow root (same caveat as `ring-*`).
90
- boxShadow: l ? "var(--malachite-segmented-control-selected-shadow, 0px 1px 5px 0px rgba(0, 0, 0, 0.1))" : void 0
90
+ boxShadow: r ? "var(--malachite-segmented-control-selected-shadow, 0px 1px 5px 0px rgba(0, 0, 0, 0.1))" : void 0
91
91
  },
92
92
  children: [
93
- e.icon ? /* @__PURE__ */ g("span", { className: "inline-flex items-center justify-center w-4 h-4 shrink-0", children: e.icon }) : null,
94
- e.label
93
+ e.icon ? /* @__PURE__ */ u("span", { className: "inline-flex items-center justify-center w-4 h-4 shrink-0", children: e.icon }) : null,
94
+ e.label,
95
+ i ? " " : null,
96
+ i ? /* @__PURE__ */ u("span", { className: "inline-flex items-center shrink-0", children: e.badge }) : null
95
97
  ]
96
98
  },
97
99
  e.value
@@ -101,7 +103,7 @@ const D = {
101
103
  );
102
104
  };
103
105
  export {
104
- K as SegmentedControl,
105
- K as default
106
+ B as SegmentedControl,
107
+ B as default
106
108
  };
107
109
  //# sourceMappingURL=SegmentedControl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"SegmentedControl.js","sources":["../../src/components/SegmentedControl.tsx"],"sourcesContent":["import React, { useCallback, useRef } from 'react';\nimport { FOCUS_RING } from './focusRing';\n\ntype SegmentedControlSize = 'small' | 'medium' | 'large';\n\ninterface SegmentedControlOption<T extends string = string> {\n value: T;\n label: string;\n icon?: React.ReactNode;\n disabled?: boolean;\n}\n\ninterface SegmentedControlProps<T extends string = string> {\n options: SegmentedControlOption<T>[];\n /** The selected option's value. A value not present in `options` renders nothing selected. */\n value: T;\n onChange: (value: T) => void;\n size?: SegmentedControlSize;\n /** Stretch the track to its container and split it into equal segments. */\n fullWidth?: boolean;\n /** Disables every segment. Individual segments can also be disabled via `options`. */\n disabled?: boolean;\n 'aria-label'?: string;\n 'aria-labelledby'?: string;\n className?: string;\n}\n\n// The ladder governs a control's outer box. The height therefore sits on the track and\n// the items fill whatever the 4px inset leaves — putting it on the item instead makes a\n// `medium` segmented control 40px tall beside a 32px Dropdown.\nconst SIZE_CLASSES: Record<SegmentedControlSize, { text: string; track: string; item: string }> = {\n small: {\n text: 'text-[length:var(--malachite-font-size-small,13px)]',\n track: 'h-[var(--malachite-control-height-small,28px)]',\n item: 'h-full px-2.5',\n },\n medium: {\n text: 'text-[length:var(--malachite-font-size-small,13px)]',\n track: 'h-[var(--malachite-control-height-medium,32px)]',\n item: 'h-full px-2.5',\n },\n large: {\n text: 'text-[length:var(--malachite-font-size-15,15px)]',\n track: 'h-[var(--malachite-control-height-large,42px)]',\n item: 'h-full px-4',\n },\n};\n\n/** Arrow keys move AND select, per the WAI-ARIA radiogroup pattern. */\nconst NAV_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);\n\n// Deliberately no `bg-*` here: each state branch below supplies exactly one\n// background-color utility. Both `bg-transparent` and `bg-[var(--…)]` are plain\n// single-class utilities in the same `@layer utilities`, so an element carrying both\n// resolves by stylesheet order — and the arbitrary value loses, silently rendering the\n// selected thumb transparent.\nconst ITEM_BASE =\n 'inline-flex items-center justify-center gap-1.5 rounded-[var(--malachite-radius-10,10px)] border-0 font-medium tracking-[0.13px] whitespace-nowrap transition-colors duration-150 ease-in-out';\n\n/**\n * A rounded track of mutually-exclusive options — the segmented \"one of these\" control.\n *\n * Owns the track background, the selected-thumb chrome and the radiogroup semantics in\n * one place, so consumers never hand-roll a track around `PillButton` (whose `default`\n * variant tokens are tuned for standalone filter pills, not for a sunken track).\n *\n * Selection is conveyed non-visually via `role=\"radio\"` + `aria-checked`, and by a\n * foreground-colour change as well as the thumb, so it does not rest on background\n * colour alone.\n */\nconst SegmentedControl = <T extends string = string>({\n options,\n value,\n onChange,\n size = 'medium',\n fullWidth = false,\n disabled = false,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledBy,\n className = '',\n}: SegmentedControlProps<T>) => {\n const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const sizeBlock = SIZE_CLASSES[size];\n\n const selectedIndex = options.findIndex((option) => option.value === value);\n // Roving tabindex: the group is a single tab stop. When the selected option is\n // missing or disabled, the first enabled option takes the stop so the control is\n // still reachable by keyboard.\n const selectedIsEnabled = selectedIndex >= 0 && !options[selectedIndex].disabled;\n const tabStopIndex = selectedIsEnabled\n ? selectedIndex\n : options.findIndex((option) => !option.disabled);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n if (disabled || !NAV_KEYS.has(event.key)) {\n return;\n }\n\n const enabled = options.reduce<\n Array<{ option: SegmentedControlOption<T>; index: number }>\n >((acc, option, index) => {\n if (!option.disabled) {\n acc.push({ option, index });\n }\n return acc;\n }, []);\n if (enabled.length === 0) {\n return;\n }\n\n // Only once we know we'll act — otherwise Home/End etc. should still scroll.\n event.preventDefault();\n\n const currentPosition = enabled.findIndex(({ option }) => option.value === value);\n let nextPosition: number;\n if (event.key === 'Home') {\n nextPosition = 0;\n } else if (event.key === 'End') {\n nextPosition = enabled.length - 1;\n } else {\n const delta = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1;\n const from = currentPosition === -1 ? 0 : currentPosition;\n nextPosition = (from + delta + enabled.length) % enabled.length;\n }\n\n const next = enabled[nextPosition];\n if (next.option.value !== value) {\n onChange(next.option.value);\n }\n itemRefs.current[next.index]?.focus();\n },\n [disabled, onChange, options, value],\n );\n\n return (\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n aria-disabled={disabled || undefined}\n onKeyDown={handleKeyDown}\n className={`${\n // One display utility, not two: `flex` and `inline-flex` are both plain\n // `display` utilities, so emitting both would resolve by stylesheet order\n // (same hazard as the background above).\n fullWidth ? 'flex w-full' : 'inline-flex'\n } ${sizeBlock.track} items-center gap-1 p-0.5 rounded-xl bg-[var(--malachite-segmented-control-track-background)] ${\n disabled ? 'opacity-50' : ''\n } ${className}`}\n >\n {options.map((option, index) => {\n const isSelected = option.value === value;\n const isDisabled = disabled || option.disabled;\n\n let colorClasses: string;\n if (isSelected) {\n colorClasses =\n 'bg-[var(--malachite-segmented-control-selected-background)] text-[var(--malachite-segmented-control-selected-foreground)]';\n } else if (isDisabled) {\n colorClasses =\n 'bg-transparent text-[var(--malachite-segmented-control-item-foreground)]';\n } else {\n colorClasses =\n 'bg-transparent text-[var(--malachite-segmented-control-item-foreground)] hover:text-[var(--malachite-segmented-control-item-hover-foreground)] cursor-pointer';\n }\n\n return (\n <button\n key={option.value}\n ref={(element) => {\n itemRefs.current[index] = element;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={isSelected}\n disabled={isDisabled}\n tabIndex={index === tabStopIndex ? 0 : -1}\n onClick={() => {\n // Radios don't toggle off on re-click.\n if (!isDisabled && !isSelected) {\n onChange(option.value);\n }\n }}\n className={`${FOCUS_RING} ${ITEM_BASE} ${sizeBlock.text} ${sizeBlock.item} ${colorClasses} ${\n fullWidth ? 'flex-1' : ''\n } ${option.disabled && !disabled ? 'opacity-50 cursor-not-allowed' : ''}`}\n style={{\n // Inline rather than a `shadow-[...]` class: Tailwind's shadow\n // utilities compose `--tw-shadow-*` custom properties that rely on\n // document-level @property registration and silently don't paint\n // inside a shadow root (same caveat as `ring-*`).\n boxShadow: isSelected\n ? 'var(--malachite-segmented-control-selected-shadow, 0px 1px 5px 0px rgba(0, 0, 0, 0.1))'\n : undefined,\n }}\n >\n {option.icon ? (\n <span className=\"inline-flex items-center justify-center w-4 h-4 shrink-0\">\n {option.icon}\n </span>\n ) : null}\n {option.label}\n </button>\n );\n })}\n </div>\n );\n};\n\nexport default SegmentedControl;\nexport { SegmentedControl };\nexport type { SegmentedControlProps, SegmentedControlOption, SegmentedControlSize };\n"],"names":["SIZE_CLASSES","NAV_KEYS","ITEM_BASE","SegmentedControl","options","value","onChange","size","fullWidth","disabled","ariaLabel","ariaLabelledBy","className","itemRefs","useRef","sizeBlock","selectedIndex","option","tabStopIndex","handleKeyDown","useCallback","event","enabled","acc","index","currentPosition","nextPosition","delta","next","_a","jsx","isSelected","isDisabled","colorClasses","jsxs","element","FOCUS_RING"],"mappings":"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"}
1
+ {"version":3,"file":"SegmentedControl.js","sources":["../../src/components/SegmentedControl.tsx"],"sourcesContent":["import React, { useCallback, useRef } from 'react';\nimport { FOCUS_RING } from './focusRing';\n\ntype SegmentedControlSize = 'small' | 'medium' | 'large';\n\ninterface SegmentedControlOption<T extends string = string> {\n value: T;\n label: string;\n icon?: React.ReactNode;\n /**\n * Small mark after the label, e.g. a compact `Tag` naming a saving. It renders inside\n * the segment's `<button>`, so it must not be interactive — no `onClick` or `onRemove`.\n */\n badge?: React.ReactNode;\n disabled?: boolean;\n}\n\ninterface SegmentedControlProps<T extends string = string> {\n options: SegmentedControlOption<T>[];\n /** The selected option's value. A value not present in `options` renders nothing selected. */\n value: T;\n onChange: (value: T) => void;\n size?: SegmentedControlSize;\n /** Stretch the track to its container and split it into equal segments. */\n fullWidth?: boolean;\n /** Disables every segment. Individual segments can also be disabled via `options`. */\n disabled?: boolean;\n 'aria-label'?: string;\n 'aria-labelledby'?: string;\n className?: string;\n}\n\n// The ladder governs a control's outer box. The height therefore sits on the track and\n// the items fill whatever the 4px inset leaves — putting it on the item instead makes a\n// `medium` segmented control 40px tall beside a 32px Dropdown.\nconst SIZE_CLASSES: Record<SegmentedControlSize, { text: string; track: string; item: string }> = {\n small: {\n text: 'text-[length:var(--malachite-font-size-small,13px)]',\n track: 'h-[var(--malachite-control-height-small,28px)]',\n item: 'h-full px-2.5',\n },\n medium: {\n text: 'text-[length:var(--malachite-font-size-small,13px)]',\n track: 'h-[var(--malachite-control-height-medium,32px)]',\n item: 'h-full px-2.5',\n },\n large: {\n text: 'text-[length:var(--malachite-font-size-15,15px)]',\n track: 'h-[var(--malachite-control-height-large,42px)]',\n item: 'h-full px-4',\n },\n};\n\n/** Arrow keys move AND select, per the WAI-ARIA radiogroup pattern. */\nconst NAV_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);\n\n// Deliberately no `bg-*` here: each state branch below supplies exactly one\n// background-color utility. Both `bg-transparent` and `bg-[var(--…)]` are plain\n// single-class utilities in the same `@layer utilities`, so an element carrying both\n// resolves by stylesheet order — and the arbitrary value loses, silently rendering the\n// selected thumb transparent.\nconst ITEM_BASE =\n 'inline-flex items-center justify-center gap-1.5 rounded-[var(--malachite-radius-10,10px)] border-0 font-medium tracking-[0.13px] whitespace-nowrap transition-colors duration-150 ease-in-out';\n\n/**\n * A rounded track of mutually-exclusive options — the segmented \"one of these\" control.\n *\n * Owns the track background, the selected-thumb chrome and the radiogroup semantics in\n * one place, so consumers never hand-roll a track around `PillButton` (whose `default`\n * variant tokens are tuned for standalone filter pills, not for a sunken track).\n *\n * Selection is conveyed non-visually via `role=\"radio\"` + `aria-checked`, and by a\n * foreground-colour change as well as the thumb, so it does not rest on background\n * colour alone.\n */\nconst SegmentedControl = <T extends string = string>({\n options,\n value,\n onChange,\n size = 'medium',\n fullWidth = false,\n disabled = false,\n 'aria-label': ariaLabel,\n 'aria-labelledby': ariaLabelledBy,\n className = '',\n}: SegmentedControlProps<T>) => {\n const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const sizeBlock = SIZE_CLASSES[size];\n\n const selectedIndex = options.findIndex((option) => option.value === value);\n // Roving tabindex: the group is a single tab stop. When the selected option is\n // missing or disabled, the first enabled option takes the stop so the control is\n // still reachable by keyboard.\n const selectedIsEnabled = selectedIndex >= 0 && !options[selectedIndex].disabled;\n const tabStopIndex = selectedIsEnabled\n ? selectedIndex\n : options.findIndex((option) => !option.disabled);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n if (disabled || !NAV_KEYS.has(event.key)) {\n return;\n }\n\n const enabled = options.reduce<\n Array<{ option: SegmentedControlOption<T>; index: number }>\n >((acc, option, index) => {\n if (!option.disabled) {\n acc.push({ option, index });\n }\n return acc;\n }, []);\n if (enabled.length === 0) {\n return;\n }\n\n // Only once we know we'll act — otherwise Home/End etc. should still scroll.\n event.preventDefault();\n\n const currentPosition = enabled.findIndex(({ option }) => option.value === value);\n let nextPosition: number;\n if (event.key === 'Home') {\n nextPosition = 0;\n } else if (event.key === 'End') {\n nextPosition = enabled.length - 1;\n } else {\n const delta = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1;\n const from = currentPosition === -1 ? 0 : currentPosition;\n nextPosition = (from + delta + enabled.length) % enabled.length;\n }\n\n const next = enabled[nextPosition];\n if (next.option.value !== value) {\n onChange(next.option.value);\n }\n itemRefs.current[next.index]?.focus();\n },\n [disabled, onChange, options, value],\n );\n\n return (\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n aria-disabled={disabled || undefined}\n onKeyDown={handleKeyDown}\n className={`${\n // One display utility, not two: `flex` and `inline-flex` are both plain\n // `display` utilities, so emitting both would resolve by stylesheet order\n // (same hazard as the background above).\n fullWidth ? 'flex w-full' : 'inline-flex'\n } ${sizeBlock.track} items-center gap-1 p-0.5 rounded-xl bg-[var(--malachite-segmented-control-track-background)] ${\n disabled ? 'opacity-50' : ''\n } ${className}`}\n >\n {options.map((option, index) => {\n const isSelected = option.value === value;\n const isDisabled = disabled || option.disabled;\n const hasBadge =\n option.badge !== undefined && option.badge !== null && option.badge !== false;\n\n let colorClasses: string;\n if (isSelected) {\n colorClasses =\n 'bg-[var(--malachite-segmented-control-selected-background)] text-[var(--malachite-segmented-control-selected-foreground)]';\n } else if (isDisabled) {\n colorClasses =\n 'bg-transparent text-[var(--malachite-segmented-control-item-foreground)]';\n } else {\n colorClasses =\n 'bg-transparent text-[var(--malachite-segmented-control-item-foreground)] hover:text-[var(--malachite-segmented-control-item-hover-foreground)] cursor-pointer';\n }\n\n return (\n <button\n key={option.value}\n ref={(element) => {\n itemRefs.current[index] = element;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={isSelected}\n disabled={isDisabled}\n tabIndex={index === tabStopIndex ? 0 : -1}\n onClick={() => {\n // Radios don't toggle off on re-click.\n if (!isDisabled && !isSelected) {\n onChange(option.value);\n }\n }}\n className={`${FOCUS_RING} ${ITEM_BASE} ${sizeBlock.text} ${sizeBlock.item} ${colorClasses} ${\n fullWidth ? 'flex-1' : ''\n } ${option.disabled && !disabled ? 'opacity-50 cursor-not-allowed' : ''}`}\n style={{\n // Inline rather than a `shadow-[...]` class: Tailwind's shadow\n // utilities compose `--tw-shadow-*` custom properties that rely on\n // document-level @property registration and silently don't paint\n // inside a shadow root (same caveat as `ring-*`).\n boxShadow: isSelected\n ? 'var(--malachite-segmented-control-selected-shadow, 0px 1px 5px 0px rgba(0, 0, 0, 0.1))'\n : undefined,\n }}\n >\n {option.icon ? (\n <span className=\"inline-flex items-center justify-center w-4 h-4 shrink-0\">\n {option.icon}\n </span>\n ) : null}\n {option.label}\n {/* The space keeps the name \"Annual Save\" rather than \"AnnualSave\";\n the flex gap already spaces the two visually. */}\n {hasBadge ? ' ' : null}\n {hasBadge ? (\n <span className=\"inline-flex items-center shrink-0\">\n {option.badge}\n </span>\n ) : null}\n </button>\n );\n })}\n </div>\n );\n};\n\nexport default SegmentedControl;\nexport { SegmentedControl };\nexport type { SegmentedControlProps, SegmentedControlOption, SegmentedControlSize };\n"],"names":["SIZE_CLASSES","NAV_KEYS","ITEM_BASE","SegmentedControl","options","value","onChange","size","fullWidth","disabled","ariaLabel","ariaLabelledBy","className","itemRefs","useRef","sizeBlock","selectedIndex","option","tabStopIndex","handleKeyDown","useCallback","event","enabled","acc","index","currentPosition","nextPosition","delta","next","_a","jsx","isSelected","isDisabled","hasBadge","colorClasses","jsxs","element","FOCUS_RING"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -3933,6 +3933,11 @@ export declare interface SegmentedControlOption<T extends string = string> {
3933
3933
  value: T;
3934
3934
  label: string;
3935
3935
  icon?: default_2.ReactNode;
3936
+ /**
3937
+ * Small mark after the label, e.g. a compact `Tag` naming a saving. It renders inside
3938
+ * the segment's `<button>`, so it must not be interactive — no `onClick` or `onRemove`.
3939
+ */
3940
+ badge?: default_2.ReactNode;
3936
3941
  disabled?: boolean;
3937
3942
  }
3938
3943
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackone/malachite",
3
- "version": "2.43.0",
3
+ "version": "2.44.0",
4
4
  "description": "StackOne's themeable React component library — Shadow DOM isolated, scoped Tailwind, theme-token driven.",
5
5
  "license": "MIT",
6
6
  "author": "StackOne",