@helloimjolopez-pathway/pathway-components 0.13.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.
@@ -314,13 +314,12 @@ ${ITEM}:is([data-selected],[data-current]) .pw-menu-item__stripe{background:${T.
314
314
  border:0;border-radius:${L.itemRadius};background:${T.avatarFill};color:${T.avatarText};font-size:${ST("font-size-xxs")};font-weight:${ST("weight-semibold")}}
315
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}}
316
316
 
317
- /* smallScreen="sheet" (G4, pending the overlay family decision): below 768 the
318
- panel opens as a bottom sheet from the BottomSheet tokens, the way the
319
- approved demo draws it. The scrim is a spread shadow, so a press outside
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
320
319
  still reaches the page and closes the menu. */
321
320
  @media (max-width: 767px){
322
321
  .pw-menu-panel[data-small-screen=sheet]{position:fixed !important;top:auto !important;bottom:0 !important;left:0 !important;right:0 !important;
323
- width:auto;max-width:${SHEET_L.maxWidth}px;margin:0 auto;max-height:calc(var(--visual-viewport-height,100vh) - ${SHEET_L.topClearance}px);
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;
324
323
  padding-top:${SHEET_L.handleZone}px;padding-bottom:env(safe-area-inset-bottom,0);
325
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}}
326
325
  .pw-menu-panel[data-small-screen=sheet]::before{content:"";position:absolute;top:${(SHEET_L.handleZone - SHEET_L.pillHeight) / 2}px;left:50%;
@@ -364,6 +363,9 @@ const pickDelay = () => motionMs("--motion-duration-3", 200);
364
363
  Aria MenuItem in actions mode and a ListBoxItem in options mode. */
365
364
  const ModeContext = createContext("actions");
366
365
 
366
+ /* How the root menu opens below 768, so a submenu opens the same way. */
367
+ const SmallScreenContext = createContext("sheet");
368
+
367
369
  /**
368
370
  * Lets a host that is not a PopoverMenu close itself when an item is chosen,
369
371
  * the way a PopoverMenu does. BottomSheet provides it around its Body, so a
@@ -555,10 +557,12 @@ export function PopoverMenuItem({
555
557
  */
556
558
  export function PopoverMenuSubmenu({ item, label, children, ...rest }) {
557
559
  useStyles();
560
+ const smallScreen = useContext(SmallScreenContext);
558
561
  return (
559
562
  <SubmenuTrigger {...rest}>
560
563
  {item}
561
- <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}>
562
566
  <PopoverMenuList label={label}>{children}</PopoverMenuList>
563
567
  </Popover>
564
568
  </SubmenuTrigger>
@@ -690,7 +694,7 @@ export function PopoverMenuList({
690
694
  * @param {"none"|"single"|"multiple"} selectionMode, selectedKeys, onSelectionChange, onAction
691
695
  * @param {ReactNode} header, footer, emptyState See PopoverMenuList
692
696
  * @param {boolean} searchable The panel opens at the fixed 360 height and keeps it while the list filters
693
- * @param {"float"|"sheet"} smallScreen Below 768: float from the trigger (default), or open as a bottom sheet. Pending decision G4
697
+ * @param {"sheet"|"float"} smallScreen Below 768: open as a bottom sheet (default), or keep floating from the trigger
694
698
  */
695
699
  export function PopoverMenu({
696
700
  type = "icon",
@@ -714,7 +718,7 @@ export function PopoverMenu({
714
718
  footer,
715
719
  emptyState,
716
720
  searchable = false,
717
- smallScreen = "float",
721
+ smallScreen = "sheet",
718
722
  maxHeight,
719
723
  className = "",
720
724
  style,
@@ -794,10 +798,12 @@ export function PopoverMenu({
794
798
  [RootMenuTriggerStateContext, state],
795
799
  [PopoverContext, { trigger: "MenuTrigger", triggerRef: wrap, placement: `bottom ${align}` }],
796
800
  ]}>
797
- <Popover className="pw-menu-panel" offset={L.offset} data-searchable={searchable || undefined}
798
- data-small-screen={smallScreen === "sheet" ? "sheet" : undefined}>
799
- {list}
800
- </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>
801
807
  </Provider>
802
808
  </span>
803
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;