@helloimjolopez-pathway/pathway-components 0.12.0 → 0.14.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.
@@ -60,6 +60,7 @@ import { Spinner } from "../spinner/spinner.jsx";
60
60
  import { Badge } from "../badge/badge.jsx";
61
61
  import { Avatar, initialsFrom } from "../avatar/avatar.jsx";
62
62
  import { Button } from "../button/button.jsx";
63
+ import { Tooltip } from "../tooltip/tooltip.jsx";
63
64
 
64
65
  // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
65
66
  // SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
@@ -191,6 +192,12 @@ export const TYPE = {
191
192
  header: { fontSize: ST("font-size-xs"), lineHeight: ST("line-height-xs-single"), fontWeight: ST("weight-semibold"), letterSpacing: ST("letter-spacing-wide") },
192
193
  };
193
194
 
195
+ /* The BottomSheet's tokens and measurements (bottom-sheet.jsx T and L), named
196
+ here rather than imported because BottomSheet imports this module. */
197
+ const SHEET_T = { surface: SC("fill-surface-elevated"), overlay: SC("scrim-subtle"), handle: SC("fill-static-neutral-strong") };
198
+ const SHEET_L = { radius: SL("cornerradius-xlarge"), pillRadius: SL("cornerradius-full"),
199
+ maxWidth: 640, topClearance: 56, handleZone: 20, pillWidth: 32, pillHeight: 4 };
200
+
194
201
  const ITEM = ".pw-menu-item";
195
202
  const live = (s) => `${ITEM}:not([data-disabled]):is(${s})`;
196
203
  const HOVER = "[data-hovered],[data-focused],[data-open],[data-force=Hover],[data-force=Focus]";
@@ -206,6 +213,9 @@ const CSS = `
206
213
  background:${T.panel};border:${L.panelBorder} solid ${T.panelBorder};border-radius:${L.panelRadius};box-shadow:${L.shadow};
207
214
  font-family:${ST("family-brand")},sans-serif}
208
215
  .pw-menu-panel[data-searchable]{height:${L.searchHeight}px}
216
+ /* React Aria stacks the panel at 100000 inline; a truncated label's Tooltip
217
+ (portalled to the body at 1000) has to sit above it. */
218
+ body:has(.pw-menu-panel) .pw-tooltip{z-index:100001}
209
219
  .pw-menu-panel[data-entering]{animation:pw-menu-in ${M("duration-4")} ${M("easing-decelerate")} both}
210
220
  .pw-menu-panel[data-placement=top][data-entering]{animation-name:pw-menu-in-up}
211
221
  .pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
@@ -304,7 +314,25 @@ ${ITEM}:is([data-selected],[data-current]) .pw-menu-item__stripe{background:${T.
304
314
  border:0;border-radius:${L.itemRadius};background:${T.avatarFill};color:${T.avatarText};font-size:${ST("font-size-xxs")};font-weight:${ST("weight-semibold")}}
305
315
  .pw-menu-trigger :is(.pw-menu-trigger__icon,.pw-menu-trigger__button,.pw-menu-trigger__avatar):focus-visible{outline:${L.focusWidth} solid ${T.focusRing}}
306
316
 
317
+ /* Below 768 the panel opens as a bottom sheet from the BottomSheet tokens, the
318
+ way the approved demo draws it (smallScreen="float" keeps it floating). The scrim is a spread shadow, so a press outside
319
+ still reaches the page and closes the menu. */
320
+ @media (max-width: 767px){
321
+ .pw-menu-panel[data-small-screen=sheet]{position:fixed !important;top:auto !important;bottom:0 !important;left:0 !important;right:0 !important;
322
+ width:auto;max-width:${SHEET_L.maxWidth}px;margin:0 auto;max-height:calc(var(--visual-viewport-height,100vh) - ${SHEET_L.topClearance}px) !important;
323
+ padding-top:${SHEET_L.handleZone}px;padding-bottom:env(safe-area-inset-bottom,0);
324
+ background:${SHEET_T.surface};border-bottom:0;border-radius:${SHEET_L.radius} ${SHEET_L.radius} 0 0;box-shadow:0 0 0 100vmax ${SHEET_T.overlay}}
325
+ .pw-menu-panel[data-small-screen=sheet]::before{content:"";position:absolute;top:${(SHEET_L.handleZone - SHEET_L.pillHeight) / 2}px;left:50%;
326
+ width:${SHEET_L.pillWidth}px;height:${SHEET_L.pillHeight}px;margin-left:-${SHEET_L.pillWidth / 2}px;border-radius:${SHEET_L.pillRadius};background:${SHEET_T.handle}}
327
+ .pw-menu-panel[data-small-screen=sheet][data-entering],.pw-menu-panel[data-small-screen=sheet][data-placement=top][data-entering]{animation:pw-menu-sheet-in ${M("duration-5")} ${M("easing-emphasized")} both}
328
+ .pw-menu-panel[data-small-screen=sheet][data-exiting]{animation:pw-menu-sheet-out ${M("duration-4")} ${M("easing-accelerate")} both}
329
+ @keyframes pw-menu-sheet-in{from{transform:translateY(100%)}to{transform:none}}
330
+ @keyframes pw-menu-sheet-out{from{transform:none}to{transform:translateY(100%)}}
331
+ }
332
+
307
333
  @media (prefers-reduced-motion: reduce){
334
+ .pw-menu-panel[data-small-screen=sheet][data-entering],.pw-menu-panel[data-small-screen=sheet][data-placement=top][data-entering]{animation:pw-menu-fade ${M("duration-2")} ${M("easing-standard")} both}
335
+ .pw-menu-panel[data-small-screen=sheet][data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
308
336
  .pw-menu-panel[data-entering],.pw-menu-panel[data-placement=top][data-entering]{animation:pw-menu-fade ${M("duration-2")} ${M("easing-standard")} both}
309
337
  .pw-menu-panel[data-exiting]{animation:pw-menu-out ${M("duration-2")} ${M("easing-standard")} both}
310
338
  @keyframes pw-menu-fade{from{opacity:0}to{opacity:1}}
@@ -325,8 +353,9 @@ function useStyles() {
325
353
 
326
354
  /* Picks are seen before the panel goes: the row shows its new state for
327
355
  Duration/3, then the panel fades (approved demo). */
328
- const pickDelay = () => (typeof document !== "undefined"
329
- && parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-3"))) || 200;
356
+ const motionMs = (name, fallback) => (typeof document !== "undefined"
357
+ && parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name))) || fallback;
358
+ const pickDelay = () => motionMs("--motion-duration-3", 200);
330
359
 
331
360
  // ─── CONTEXTS ─────────────────────────────────────────────────────────────────
332
361
 
@@ -334,6 +363,9 @@ const pickDelay = () => (typeof document !== "undefined"
334
363
  Aria MenuItem in actions mode and a ListBoxItem in options mode. */
335
364
  const ModeContext = createContext("actions");
336
365
 
366
+ /* How the root menu opens below 768, so a submenu opens the same way. */
367
+ const SmallScreenContext = createContext("sheet");
368
+
337
369
  /**
338
370
  * Lets a host that is not a PopoverMenu close itself when an item is chosen,
339
371
  * the way a PopoverMenu does. BottomSheet provides it around its Body, so a
@@ -379,6 +411,32 @@ function Trailing({ trailing, hasSubmenu }) {
379
411
  return null;
380
412
  }
381
413
 
414
+ /* A label cut off by the panel width shows the full text in the real Tooltip:
415
+ on hover after Duration/4, on keyboard focus at once (approved demo). The
416
+ item's accessible name already carries the full label, so the tooltip is
417
+ visual only and leaves the item's ARIA alone. */
418
+ function ItemLabel({ label, s }) {
419
+ const ref = useRef(null);
420
+ const [cut, setCut] = useState(false);
421
+ const [hoverOpen, setHoverOpen] = useState(false);
422
+ const engaged = !!(s.isHovered || s.isFocusVisible);
423
+ useEffect(() => {
424
+ const el = ref.current;
425
+ setCut(!!el && el.scrollWidth > el.clientWidth + 1);
426
+ }, [engaged, label]);
427
+ useEffect(() => {
428
+ if (!s.isHovered) { setHoverOpen(false); return undefined; }
429
+ const t = setTimeout(() => setHoverOpen(true), motionMs("--motion-duration-4", 300));
430
+ return () => clearTimeout(t);
431
+ }, [s.isHovered]);
432
+ const open = cut && typeof label === "string" && (!!s.isFocusVisible || hoverOpen);
433
+ return (
434
+ <Tooltip content={typeof label === "string" ? label : undefined} open={open} manageAria={false} align="start">
435
+ <span ref={ref} className="pw-menu-item__label">{label}</span>
436
+ </Tooltip>
437
+ );
438
+ }
439
+
382
440
  /* The one item anatomy: [stripe] [indicator + visual] [label, description or
383
441
  reason] [trailing]. The indicator is the real Checkbox or Radio, visual
384
442
  only: the item is the control and owns the role, the state and the target. */
@@ -413,10 +471,10 @@ function ItemContent({ p, s }) {
413
471
  <span className="pw-menu-item__text" data-inline={inline || undefined}>
414
472
  {p.badges && p.badges.length ? (
415
473
  <span className="pw-menu-item__label-row">
416
- <span className="pw-menu-item__label">{p.label}</span>
474
+ <ItemLabel label={p.label} s={s} />
417
475
  {p.badges.map((b) => <Badge key={b.label} label={b.label} status={b.status || "Neutral"} size="Medium" badgeStyle="Outline & Fill" />)}
418
476
  </span>
419
- ) : <span className="pw-menu-item__label">{p.label}</span>}
477
+ ) : <ItemLabel label={p.label} s={s} />}
420
478
  {inline ? <span className="pw-menu-item__desc">{p.inlineText}</span>
421
479
  : p.description ? <span className="pw-menu-item__desc">{p.description}</span> : null}
422
480
  {p.reason && (
@@ -499,10 +557,12 @@ export function PopoverMenuItem({
499
557
  */
500
558
  export function PopoverMenuSubmenu({ item, label, children, ...rest }) {
501
559
  useStyles();
560
+ const smallScreen = useContext(SmallScreenContext);
502
561
  return (
503
562
  <SubmenuTrigger {...rest}>
504
563
  {item}
505
- <Popover className="pw-menu-panel" placement="end top" offset={-4}>
564
+ <Popover className="pw-menu-panel" placement="end top" offset={-4}
565
+ data-small-screen={smallScreen === "sheet" ? "sheet" : undefined}>
506
566
  <PopoverMenuList label={label}>{children}</PopoverMenuList>
507
567
  </Popover>
508
568
  </SubmenuTrigger>
@@ -634,6 +694,7 @@ export function PopoverMenuList({
634
694
  * @param {"none"|"single"|"multiple"} selectionMode, selectedKeys, onSelectionChange, onAction
635
695
  * @param {ReactNode} header, footer, emptyState See PopoverMenuList
636
696
  * @param {boolean} searchable The panel opens at the fixed 360 height and keeps it while the list filters
697
+ * @param {"sheet"|"float"} smallScreen Below 768: open as a bottom sheet (default), or keep floating from the trigger
637
698
  */
638
699
  export function PopoverMenu({
639
700
  type = "icon",
@@ -657,6 +718,7 @@ export function PopoverMenu({
657
718
  footer,
658
719
  emptyState,
659
720
  searchable = false,
721
+ smallScreen = "sheet",
660
722
  maxHeight,
661
723
  className = "",
662
724
  style,
@@ -736,9 +798,12 @@ export function PopoverMenu({
736
798
  [RootMenuTriggerStateContext, state],
737
799
  [PopoverContext, { trigger: "MenuTrigger", triggerRef: wrap, placement: `bottom ${align}` }],
738
800
  ]}>
739
- <Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}>
740
- {list}
741
- </Popover>
801
+ <SmallScreenContext.Provider value={smallScreen}>
802
+ <Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}
803
+ data-small-screen={smallScreen === "sheet" ? "sheet" : undefined}>
804
+ {list}
805
+ </Popover>
806
+ </SmallScreenContext.Provider>
742
807
  </Provider>
743
808
  </span>
744
809
  );
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Select, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the Select
5
+ * implementation; the standalone demo (select.html), the Storybook stories and
6
+ * the <pathway-select> web component all consume this.
7
+ *
8
+ * The control people pick from to change what a table, list, dashboard or
9
+ * widget shows. Nothing it does is saved with a record. Three types:
10
+ * Dropdown opens the Popover Menu and shows the pick ("Status: Cleared")
11
+ * Single click one yes or no filter, a toggle button ("Overdue")
12
+ * Opens panel the entry to advanced filters; the page owns the panel and
13
+ * the Select shows On while any of its filters are applied
14
+ *
15
+ * A saved form value is Input, Type Select, not this.
16
+ *
17
+ * Spec: components/select/select-spec.md
18
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40006526-36975
19
+ */
20
+
21
+ import React, { forwardRef, useCallback, useLayoutEffect, useRef, useState } from "react";
22
+ import { Tooltip } from "../tooltip/tooltip.jsx";
23
+ import { Icon } from "../icon/icon.jsx";
24
+ import { Button } from "../button/button.jsx";
25
+ import { Avatar, initialsFrom } from "../avatar/avatar.jsx";
26
+ import { PopoverMenu, PopoverMenuItem } from "../popover-menu/popover-menu.jsx";
27
+ import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
28
+ import { FOCUS_RING_LAYER, injectFocusRing } from "../_shared/focus-ring.js";
29
+
30
+ // ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
31
+ // SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
32
+ // CL = contextual-layout-units (layout-contextual.css), ST = semantic-type
33
+ // (type.css), M = motion (motion.css).
34
+ const SC = (p) => `var(--semantic-color-${p})`;
35
+ const SL = (p) => `var(--semantic-layout-units-${p})`;
36
+ const CL = (p) => `var(--contextual-layout-units-${p})`;
37
+ const ST = (p) => `var(--semantic-type-${p})`;
38
+ const M = (p) => `var(--motion-${p})`;
39
+
40
+ const ramp = (prefix) => ({ rest: SC(`${prefix}-rest`), hover: SC(`${prefix}-hover`), pressed: SC(`${prefix}-pressed`) });
41
+
42
+ export const SELECT_TYPES = ["Dropdown", "Single click", "Opens panel"];
43
+
44
+ // ─── COLOUR TOKENS ────────────────────────────────────────────────────────────
45
+ // Read off Figma set 40006526:36975, spec §3.1 to §3.3. Off has no fill at rest.
46
+ export const OFF = {
47
+ fill: { rest: "transparent", hover: SC("fill-action-secondary-rest"), pressed: SC("fill-action-secondary-hover") },
48
+ stroke: { rest: SC("stroke-static-neutral-strong"), hover: SC("stroke-action-secondary-rest"), pressed: SC("stroke-action-secondary-hover") },
49
+ fg: ramp("foreground-action-secondary"),
50
+ };
51
+
52
+ // On: the picked fill and the strong outline (D11, D12). The text is one token
53
+ // in every state so a pick never shifts while the pointer moves over it.
54
+ const ON_FG = SC("foreground-action-primary-on-subtle-pressed");
55
+ export const ON = {
56
+ fill: { rest: SC("fill-action-selection-selected"), hover: SC("fill-action-selection-hover"), pressed: SC("fill-action-selection-hover") },
57
+ stroke: ramp("stroke-action-primary-strong"),
58
+ fg: { rest: ON_FG, hover: ON_FG, pressed: ON_FG },
59
+ };
60
+
61
+ export const DISABLED = {
62
+ fill: SC("fill-action-disabled"),
63
+ stroke: SC("stroke-action-disabled"),
64
+ fg: SC("foreground-action-disabled"),
65
+ };
66
+
67
+ export const PILL = {
68
+ fill: SC("fill-surface-elevated"),
69
+ stroke: SC("stroke-action-primary-subtle-rest"),
70
+ fg: ON_FG,
71
+ };
72
+
73
+ // ─── SHARED LAYOUT AND MOTION ─────────────────────────────────────────────────
74
+ export const T = {
75
+ radius: CL("field-cornerradius"), // the same corners as Input
76
+ border: SL("borderwidth-base"), // heavier than Button's, so Select reads apart
77
+ pillBorder: SL("borderwidth-thin"),
78
+ pillRadius: SL("cornerradius-full"),
79
+ pillPadH: SL("padding-xxtight"),
80
+ textGap: SL("gap-xxtight"), // prefix, value and +N pill
81
+ touchW: SL("accessibility-touch-target-aa-width"), // 44
82
+ touchH: SL("accessibility-touch-target-aa-height"), // 44
83
+ // The ring is the control's own outline and takes the border's place
84
+ // (spec §3.6), as FOCUS_RING_REPLACE does for stylesheet-styled controls.
85
+ focusOutline: `${CL("focusring-width")} solid ${SC("stroke-focusring-base")}`,
86
+ colour: `${M("duration-2")} ${M("easing-standard")}`,
87
+ chevron: `transform ${M("duration-4")} ${M("easing-standard")}`,
88
+ };
89
+
90
+ const TEXT = (size, lineHeight, tracking) => ({
91
+ fontFamily: ST("family-brand"),
92
+ fontSize: ST(`font-size-${size}`),
93
+ lineHeight: ST(`line-height-${lineHeight}`),
94
+ letterSpacing: ST(`letter-spacing-${tracking}`),
95
+ });
96
+
97
+ // ─── SIZE TABLE ───────────────────────────────────────────────────────────────
98
+ // Heights, slots, glyphs and maximum widths are measurements (spec §4, CLAUDE.md
99
+ // §5); padding, gaps and type are tokens. square is the icon only size: XS is 28
100
+ // because its 16 slot plus XSmall padding makes 28, S and M take their height.
101
+ export const SIZES = {
102
+ XS: { height: 30, square: 28, padH: CL("button-padding-xsmall-horizontal"), slotPad: SL("padding-xxxtight"), slot: 16, icon: 12, chevSlot: 16, chev: 16, valueMax: 140, labelMax: 168, ...TEXT("xs", "xs-single", "extraspacious"), pill: TEXT("xs", "xs-single", "extraspacious") },
103
+ S: { height: 34, square: 34, padH: CL("button-padding-small-horizontal"), slotPad: SL("padding-xxtight"), slot: 22, icon: 14, chevSlot: 22, chev: 18, valueMax: 140, labelMax: 168, ...TEXT("s", "s-single", "spacious"), pill: TEXT("xs", "xs-single", "extraspacious") },
104
+ M: { height: 40, square: 40, padH: CL("button-padding-medium-horizontal"), slotPad: SL("padding-xxxtight"), slot: 24, icon: 16, chevSlot: 24, chev: 20, valueMax: 170, labelMax: 200, ...TEXT("r", "r-single", "wide"), pill: TEXT("s", "s-single", "spacious") },
105
+ };
106
+
107
+ const W = { medium: ST("weight-medium"), semibold: ST("weight-semibold") };
108
+
109
+ const isFocusVisible = (e) => { try { return e.target.matches(":focus-visible"); } catch { return true; } };
110
+
111
+ // ─── Pieces ───────────────────────────────────────────────────────────────────
112
+ const Slot = ({ size, children }) => (
113
+ <span aria-hidden="true" style={{ width: size, height: size, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>{children}</span>
114
+ );
115
+
116
+ const cut = (max) => ({ display: "block", minWidth: 0, maxWidth: max, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
117
+
118
+ /** The +N or applied count: never truncates (spec §7). */
119
+ const CountPill = ({ sz, disabled, children, flush = false }) => (
120
+ <span aria-hidden="true" className="pw-select__count" style={{
121
+ flexShrink: 0, boxSizing: "border-box", display: "inline-flex", alignItems: "center",
122
+ marginLeft: flush ? 0 : T.textGap, paddingLeft: T.pillPadH, paddingRight: T.pillPadH,
123
+ borderRadius: T.pillRadius, border: `${T.pillBorder} solid ${disabled ? DISABLED.stroke : PILL.stroke}`,
124
+ background: disabled ? DISABLED.fill : PILL.fill, color: disabled ? DISABLED.fg : PILL.fg,
125
+ ...sz.pill, fontWeight: W.medium,
126
+ }}>{children}</span>
127
+ );
128
+
129
+ // ─── Trigger ──────────────────────────────────────────────────────────────────
130
+ // The shared outer frame (touch area) and Container.Main for every type.
131
+ const Trigger = forwardRef(function Trigger({
132
+ sz, on, disabled, forceState, square, width, children, tooltip, tooltipPurpose = "description", ...rest
133
+ }, ref) {
134
+ const noMotion = useReducedMotion();
135
+ const [hover, setHover] = useState(false);
136
+ const [pressed, setPressed] = useState(false);
137
+ const [focused, setFocused] = useState(false);
138
+ const open = rest["aria-expanded"] === true;
139
+
140
+ const look = on ? ON : OFF;
141
+ const state = disabled ? "disabled"
142
+ : forceState === "pressed" || pressed || open ? "pressed"
143
+ : forceState === "hover" || hover ? "hover"
144
+ : "rest";
145
+ const ring = !disabled && (forceState === "focused" || focused);
146
+
147
+ const main = {
148
+ display: "flex", alignItems: "center", justifyContent: square ? "center" : "flex-start",
149
+ boxSizing: "border-box", height: square ? sz.square : sz.height,
150
+ width: square ? sz.square : width, minWidth: 0,
151
+ paddingLeft: square ? 0 : sz.padH, paddingRight: square ? 0 : sz.padH,
152
+ borderRadius: T.radius,
153
+ background: disabled ? DISABLED.fill : look.fill[state],
154
+ color: disabled ? DISABLED.fg : look.fg[state],
155
+ // Inset so the stroke never adds width; while focused the ring replaces it.
156
+ boxShadow: ring ? "none" : `inset 0 0 0 ${T.border} ${disabled ? DISABLED.stroke : look.stroke[state]}`,
157
+ outline: ring ? T.focusOutline : "none",
158
+ outlineOffset: 0,
159
+ fontFamily: sz.fontFamily, fontSize: sz.fontSize, lineHeight: sz.lineHeight, letterSpacing: sz.letterSpacing,
160
+ fontWeight: W.medium, whiteSpace: "nowrap",
161
+ transition: noMotion ? "none" : `background-color ${T.colour}, color ${T.colour}, box-shadow ${T.colour}`,
162
+ };
163
+
164
+ const outer = {
165
+ position: "relative", zIndex: ring ? 1 : undefined,
166
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
167
+ minHeight: T.touchH, minWidth: T.touchW, maxWidth: "100%",
168
+ padding: 0, margin: 0, border: 0, background: "transparent", outline: "none",
169
+ font: "inherit", color: "inherit", cursor: disabled ? "not-allowed" : "pointer",
170
+ WebkitTapHighlightColor: "transparent",
171
+ };
172
+
173
+ const { onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, onKeyDown, onKeyUp, ...pass } = rest;
174
+ const button = (
175
+ <button
176
+ ref={ref}
177
+ type="button"
178
+ disabled={disabled}
179
+ data-selected={on || undefined}
180
+ style={outer}
181
+ {...pass}
182
+ onPointerEnter={(e) => { setHover(true); onPointerEnter?.(e); }}
183
+ onPointerLeave={(e) => { setHover(false); setPressed(false); onPointerLeave?.(e); }}
184
+ onPointerDown={(e) => { setPressed(true); onPointerDown?.(e); }}
185
+ onPointerUp={(e) => { setPressed(false); onPointerUp?.(e); }}
186
+ onFocus={(e) => { setFocused(isFocusVisible(e)); onFocus?.(e); }}
187
+ onBlur={(e) => { setFocused(false); setPressed(false); onBlur?.(e); }}
188
+ onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") setPressed(true); onKeyDown?.(e); }}
189
+ onKeyUp={(e) => { setPressed(false); onKeyUp?.(e); }}
190
+ >
191
+ <span className="pw-select__main" style={main}>{children}</span>
192
+ </button>
193
+ );
194
+ // The Tooltip shows the full text when it is cut off, and the label on icon
195
+ // only. The button names itself, so the Tooltip adds no ARIA of its own.
196
+ return (
197
+ <Tooltip content={tooltip} purpose={tooltipPurpose} disabled={!tooltip || disabled || open} manageAria={false}>
198
+ {button}
199
+ </Tooltip>
200
+ );
201
+ });
202
+
203
+ // Is any marked text inside the node cut off? Measured after layout.
204
+ function useCutOff(deps) {
205
+ const ref = useRef(null);
206
+ const [isCut, setCut] = useState(false);
207
+ useLayoutEffect(() => {
208
+ const node = ref.current;
209
+ if (!node) return;
210
+ const check = () => setCut([...node.querySelectorAll("[data-cut]")].some((n) => n.scrollWidth > n.clientWidth + 0.5));
211
+ check();
212
+ if (typeof ResizeObserver === "undefined") return;
213
+ const ro = new ResizeObserver(check);
214
+ ro.observe(node);
215
+ return () => ro.disconnect();
216
+ }, deps); // eslint-disable-line react-hooks/exhaustive-deps
217
+ return [ref, isCut];
218
+ }
219
+
220
+ // ─── Single click ─────────────────────────────────────────────────────────────
221
+ const SingleClick = forwardRef(function SingleClick({ sz, label, icon, showLabel, selected: selectedProp, defaultSelected = false, onSelectedChange, disabled, forceState, width, ...rest }, ref) {
222
+ const controlled = selectedProp !== undefined;
223
+ const [inner, setInner] = useState(defaultSelected);
224
+ const on = controlled ? selectedProp : inner;
225
+ const [boxRef, isCut] = useCutOff([label, showLabel, sz]);
226
+ const square = !showLabel;
227
+ const toggle = () => { if (disabled) return; const next = !on; if (!controlled) setInner(next); onSelectedChange?.(next); };
228
+ return (
229
+ <span ref={boxRef} style={{ display: "inline-flex", maxWidth: "100%" }}>
230
+ <Trigger ref={ref} sz={sz} on={on} disabled={disabled} forceState={forceState} square={square} width={width}
231
+ aria-pressed={on} aria-label={square ? label : undefined}
232
+ tooltip={square || isCut ? label : undefined} tooltipPurpose={square ? "label" : "description"}
233
+ onClick={toggle} {...rest}>
234
+ {icon && <Slot size={sz.slot}><Icon name={icon} size={sz.icon} fill={on ? 1 : 0} /></Slot>}
235
+ {showLabel && <span style={{ minWidth: 0, paddingLeft: sz.slotPad, paddingRight: sz.slotPad }}><span data-cut style={cut(sz.labelMax)}>{label}</span></span>}
236
+ </Trigger>
237
+ </span>
238
+ );
239
+ });
240
+
241
+ // ─── Opens panel ──────────────────────────────────────────────────────────────
242
+ const OpensPanel = forwardRef(function OpensPanel({ sz, label = "Filters", icon, showLabel, count = 0, showCount = false, selected, onPress, isOpen = false, disabled, forceState, width, ...rest }, ref) {
243
+ const on = selected ?? count > 0;
244
+ const [boxRef, isCut] = useCutOff([label, showLabel, sz]);
245
+ const square = !showLabel;
246
+ const name = on ? `${label}, ${count} applied` : label;
247
+ return (
248
+ <span ref={boxRef} style={{ display: "inline-flex", maxWidth: "100%" }}>
249
+ <Trigger ref={ref} sz={sz} on={on} disabled={disabled} forceState={forceState} square={square && !(on && showCount)} width={width}
250
+ aria-haspopup="dialog" aria-expanded={isOpen} aria-label={name}
251
+ tooltip={square || isCut ? label : undefined} tooltipPurpose={square ? "label" : "description"}
252
+ onClick={() => { if (!disabled) onPress?.(); }} {...rest}>
253
+ {icon && <Slot size={sz.slot}><Icon name={icon} size={sz.icon} fill={on ? 1 : 0} /></Slot>}
254
+ {showLabel && <span style={{ minWidth: 0, paddingLeft: sz.slotPad, paddingRight: sz.slotPad }}><span data-cut style={cut(sz.labelMax)}>{label}</span></span>}
255
+ {/* The count follows the label slot with no extra gap (spec §4). */}
256
+ {on && showCount && <CountPill sz={sz} disabled={disabled} flush>{count}</CountPill>}
257
+ </Trigger>
258
+ </span>
259
+ );
260
+ });
261
+
262
+ // ─── Dropdown ─────────────────────────────────────────────────────────────────
263
+ /**
264
+ * Text and name for the Dropdown (spec §5 and §13.2). The first option is the
265
+ * one it rests on: "All" with a label, the default without one.
266
+ */
267
+ export function dropdownText({ label, options = [], mode = "single", value, showPrefix = true, restValue }) {
268
+ const rest = options[0];
269
+ const picks = mode === "multiple"
270
+ ? (Array.isArray(value) ? value : []).map((id) => options.find((o) => o.id === id)).filter((o) => o && o !== rest)
271
+ : [options.find((o) => o.id === value)].filter((o) => o && o !== rest);
272
+ const first = picks[0] || rest;
273
+ const shown = picks.length ? first.label : (restValue ?? (first ? first.label : ""));
274
+ const full = picks.length ? picks.map((o) => o.label).join(", ") : shown;
275
+ return {
276
+ on: picks.length > 0,
277
+ picks,
278
+ shown,
279
+ more: Math.max(0, picks.length - 1),
280
+ name: showPrefix ? `${label}: ${full}` : full,
281
+ };
282
+ }
283
+
284
+ // The leading slot's Avatar size per Select size: it fills the icon slot.
285
+ const AVATAR_IN = { XS: "XXXS", S: "XXS", M: "XS" };
286
+
287
+ const Dropdown = forwardRef(function Dropdown({
288
+ sz, size, label, options = [], mode = "single", value: valueProp, defaultValue, onChange,
289
+ showPrefix = true, showLeading = true, leading = "filter_list", disabled, forceState, width,
290
+ open: openProp, onOpenChange,
291
+ restValue, // Figma Rest Value: the text at rest when it differs from the first option ("From")
292
+ ...rest
293
+ }, ref) {
294
+ const multiple = mode === "multiple";
295
+ const restOption = options[0];
296
+ const controlled = valueProp !== undefined;
297
+ const [inner, setInner] = useState(defaultValue ?? (multiple ? [] : restOption?.id));
298
+ const value = controlled ? valueProp : inner;
299
+ const [openInner, setOpenInner] = useState(false);
300
+ const open = openProp ?? openInner;
301
+ const noMotion = useReducedMotion();
302
+ const mainRef = useRef(null);
303
+
304
+ const commit = useCallback((next) => { if (!controlled) setInner(next); onChange?.(next); }, [controlled, onChange]);
305
+ const setOpen = useCallback((v) => { if (openProp === undefined) setOpenInner(v); onOpenChange?.(v); }, [openProp, onOpenChange]);
306
+
307
+ const t = dropdownText({ label, options, mode, value, showPrefix, restValue });
308
+ const [boxRef, isCut] = useCutOff([t.name, sz]);
309
+
310
+ const picked = multiple ? (t.picks.length ? t.picks.map((o) => o.id) : [restOption?.id]) : [value ?? restOption?.id];
311
+ const onSelectionChange = (keys) => {
312
+ const next = [...keys];
313
+ if (!multiple) { commit(next[0] ?? restOption?.id); return; }
314
+ const chose = next.filter((k) => !picked.includes(k));
315
+ commit(chose.includes(restOption?.id) ? [] : next.filter((k) => k !== restOption?.id));
316
+ };
317
+ const clear = () => { commit(multiple ? [] : restOption?.id); mainRef.current?.focus(); };
318
+
319
+ const typeAhead = (e) => {
320
+ if (disabled || multiple || open || e.key.length !== 1 || !/\S/.test(e.key) || e.metaKey || e.ctrlKey || e.altKey) return;
321
+ const k = e.key.toLowerCase();
322
+ const at = Math.max(0, options.findIndex((o) => o.id === value));
323
+ for (let i = 1; i <= options.length; i++) {
324
+ const o = options[(at + i) % options.length];
325
+ if (o && !o.disabled && o.label.toLowerCase().startsWith(k)) { commit(o.id); break; }
326
+ }
327
+ };
328
+
329
+ // Leading: the icon, or the first pick's avatar in its place (D18, D19).
330
+ // Avatar draws itself: photo, initials or icon is its own call at every size.
331
+ const avatarSize = AVATAR_IN[size] || "XXXS";
332
+ const person = t.picks.find((o) => o.avatar);
333
+ const lead = person
334
+ ? <Slot size={sz.slot}><Avatar size={avatarSize} name={person.avatar.name || person.label} src={person.avatar.src}
335
+ text={initialsFrom(person.avatar.name || person.label)} aria-hidden="true" /></Slot>
336
+ : showLeading && leading
337
+ ? <Slot size={sz.slot}>{typeof leading === "string" ? <Icon name={leading} size={sz.icon} /> : leading}</Slot>
338
+ : null;
339
+ // One pick: a dismiss takes the chevron's place (D20). Several picks keep the
340
+ // chevron; Clear all lives in the Menu footer (D22).
341
+ const showDismiss = !disabled && t.on && !multiple;
342
+ const showChevron = !showDismiss;
343
+
344
+ const renderTrigger = ({ title, onKeyDown, ...menuProps }) => (
345
+ <Trigger ref={(n) => { mainRef.current = n; if (typeof ref === "function") ref(n); else if (ref) ref.current = n; }}
346
+ sz={sz} on={t.on} disabled={disabled} forceState={forceState} width={width}
347
+ {...menuProps} aria-label={t.name} tooltip={isCut ? t.name : undefined}
348
+ onKeyDown={(e) => { onKeyDown?.(e); typeAhead(e); }} {...rest}>
349
+ {lead}
350
+ <span style={{ display: "flex", alignItems: "center", minWidth: 0, flex: "1 1 auto", paddingLeft: sz.slotPad, paddingRight: sz.slotPad }}>
351
+ {showPrefix && <span style={{ flexShrink: 0, marginRight: T.textGap }}>{label}:</span>}
352
+ <span data-cut style={{ ...cut(sz.valueMax), fontWeight: showPrefix ? W.semibold : W.medium }}>{t.shown}</span>
353
+ {t.more > 0 && <CountPill sz={sz} disabled={disabled}>+{t.more}</CountPill>}
354
+ </span>
355
+ {/* The dismiss button sits over this slot; the slot keeps the width steady. */}
356
+ {showDismiss && <Slot size={sz.chevSlot} />}
357
+ {showChevron && (
358
+ <Slot size={sz.chevSlot}>
359
+ <Icon name="arrow_drop_down" size={sz.chev} style={{ transform: open ? "rotate(180deg)" : "none", transition: noMotion ? "none" : T.chevron }} />
360
+ </Slot>
361
+ )}
362
+ </Trigger>
363
+ );
364
+
365
+ // Its own button, never inside the trigger: one press clears back to the
366
+ // rest value, then focus returns to the Select (spec §9).
367
+ // The dismiss is a 44 x 44 button over the chevron's slot; its ring goes on
368
+ // the drawn slot (FOCUS_RING_LAYER), and the Tooltip names it on hover and focus.
369
+ injectFocusRing();
370
+ const clearName = `Clear ${label || "filter"}`;
371
+ const dismiss = showDismiss && (
372
+ <Tooltip content={clearName} purpose="label" manageAria={false}>
373
+ <button type="button" aria-label={clearName} onClick={clear} className={`pw-select__clear ${FOCUS_RING_LAYER}`}
374
+ style={{
375
+ position: "absolute", top: "50%", transform: "translateY(-50%)",
376
+ right: `calc(${sz.padH} - ${(44 - sz.chevSlot) / 2}px)`,
377
+ width: T.touchW, height: T.touchH, display: "inline-flex", alignItems: "center", justifyContent: "center",
378
+ padding: 0, margin: 0, border: 0, background: "transparent", cursor: "pointer", color: ON.fg.rest, zIndex: 2,
379
+ }}>
380
+ <span data-pw-ring-target="" style={{ width: sz.chevSlot, height: sz.chevSlot, borderRadius: T.pillRadius, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
381
+ <Icon name="close" size={sz.icon} />
382
+ </span>
383
+ </button>
384
+ </Tooltip>
385
+ );
386
+
387
+ // Every row in a list takes the same parts so the labels line up: a person
388
+ // row takes their Avatar (24, the Menu's one-line size), and when any row
389
+ // has one, the rest option takes the Select's rest icon.
390
+ const anyAvatar = options.some((o) => o.avatar);
391
+ const rowLead = (o) => o.avatar ? { type: "avatar", size: "XS", name: o.avatar.name || o.label, src: o.avatar.src }
392
+ : o.leading ? o.leading
393
+ : anyAvatar && o === restOption && typeof leading === "string" ? leading
394
+ : undefined;
395
+
396
+ return (
397
+ <span ref={boxRef} style={{ position: "relative", display: "inline-flex", maxWidth: "100%" }}>
398
+ <PopoverMenu mode="options" label={label || t.name} align="start" smallScreen="sheet"
399
+ selectionMode={multiple ? "multiple" : "single"} selectedKeys={new Set(picked)} onSelectionChange={onSelectionChange}
400
+ open={disabled ? false : open} onOpenChange={setOpen} renderTrigger={renderTrigger}
401
+ footer={multiple && t.picks.length ? <Button size="S" buttonStyle="Outlined" type="Secondary" text="Clear all" onClick={() => commit([])} /> : undefined}>
402
+ {options.map((o) => (
403
+ <PopoverMenuItem key={o.id} id={o.id} label={o.label} disabled={o.disabled}
404
+ leading={rowLead(o)} indicator={multiple ? "checkbox" : "check"} />
405
+ ))}
406
+ </PopoverMenu>
407
+ {dismiss}
408
+ </span>
409
+ );
410
+ });
411
+
412
+ // ─── Select ───────────────────────────────────────────────────────────────────
413
+ /**
414
+ * @param {"Dropdown"|"Single click"|"Opens panel"} type Figma `Type`
415
+ * @param {"XS"|"S"|"M"} size Figma `Size`; pick the size of the Buttons in the row
416
+ * @param {string} label the question ("Status"), the filter ("Overdue"), or "Filters"
417
+ * @param {boolean} disabled
418
+ * @param {number} width set it wider than its text; it never truncates below its maximum
419
+ * @param {"hover"|"pressed"|"focused"|null} forceState visual only, for state matrices
420
+ *
421
+ * Dropdown:
422
+ * @param {Array<{id:string,label:string,leading?:string,avatar?:{name:string,src?:string},disabled?:boolean}>} options the first is what it rests on (All, or the default)
423
+ * @param {"single"|"multiple"} mode
424
+ * @param {string|string[]} value / defaultValue option id, or ids for multiple
425
+ * @param {Function} onChange (value) => void
426
+ * @param {boolean} showPrefix Figma `Show Prefix`; off is the no label kind ("All accounts")
427
+ * @param {boolean} showLeading Figma `Show Leading`
428
+ * @param {string|React.ReactNode} leading Figma `Leading`: a Material Symbols name or an Avatar
429
+ * @param {boolean} open / onOpenChange controlled open state of its Popover Menu
430
+ *
431
+ * Single click:
432
+ * @param {string} icon Figma `Icon` / `Filled Icon`: FILL 0 Off, FILL 1 On
433
+ * @param {boolean} showLabel Figma `Show Label`; off is icon only
434
+ * @param {boolean} selected / defaultSelected, onSelectedChange(selected)
435
+ *
436
+ * Opens panel:
437
+ * @param {number} count how many of its filters are applied; On while above 0
438
+ * @param {boolean} showCount Figma `Show Count`
439
+ * @param {Function} onPress open the page's dialog, drawer or sheet
440
+ * @param {boolean} isOpen the panel is showing (aria-expanded)
441
+ */
442
+ export const Select = forwardRef(function Select({ type = "Dropdown", size = "XS", showLabel = true, icon = "filter_alt", ...props }, ref) {
443
+ const sz = SIZES[size] || SIZES.XS;
444
+ if (type === "Single click") return <SingleClick ref={ref} sz={sz} icon={icon} showLabel={showLabel} {...props} />;
445
+ if (type === "Opens panel") return <OpensPanel ref={ref} sz={sz} icon={icon} showLabel={showLabel} {...props} />;
446
+ return <Dropdown ref={ref} sz={sz} size={size} {...props} />;
447
+ });
448
+
449
+ export default Select;