@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.
- package/components/popover-menu/popover-menu.jsx +17 -11
- package/components/select/select.jsx +449 -0
- package/dist/pathway-accounting-sidenav.js +11 -12
- package/dist/pathway-components.js +201 -182
- package/dist/pathway-sidenav.js +11 -12
- package/package.json +2 -1
|
@@ -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
|
-
/*
|
|
318
|
-
|
|
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 {"
|
|
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 = "
|
|
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
|
-
<
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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;
|