@pharos-ds/core 1.0.5 → 1.0.7-canary.8f6397b

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pharos-ds/core",
3
- "version": "1.0.5",
3
+ "version": "1.0.7-canary.8f6397b",
4
4
  "displayName": "Pharos Core",
5
5
  "description": "Pharos component library. Accessible, themeable React components with spacing, dark mode, and StyleX styling.",
6
6
  "author": "Caio Silvestre",
@@ -22,7 +22,7 @@ export const docs = {
22
22
  {className: 'pharos-side-nav', visualProps: ['mode']},
23
23
  {className: 'pharos-side-nav-heading'},
24
24
  {className: 'pharos-side-nav-item', visualProps: ['size'], states: ['selected']},
25
- {className: 'pharos-side-nav-section'},
25
+ {className: 'pharos-side-nav-section', states: ['collapsed']},
26
26
  ],
27
27
  },
28
28
  description: 'Container with five zones: header, topContent, children (scrollable), footer, and footerIcons. Supports collapsible mode.',
@@ -918,6 +918,189 @@ describe('SideNavSection', () => {
918
918
  });
919
919
  });
920
920
 
921
+ describe('SideNavSection — collapsible', () => {
922
+ const items = (
923
+ <>
924
+ <SideNavItem label="Dashboard" />
925
+ <SideNavItem label="Reports" />
926
+ </>
927
+ );
928
+
929
+ function getItemsRegion(button: HTMLElement) {
930
+ const id = button.getAttribute('aria-controls');
931
+ expect(id).toBeTruthy();
932
+ return document.getElementById(id!)!;
933
+ }
934
+
935
+ it('is not collapsible by default (no toggle button)', () => {
936
+ render(<SideNavSection title="Main">{items}</SideNavSection>);
937
+ expect(
938
+ screen.queryByRole('button', {name: 'Main'}),
939
+ ).not.toBeInTheDocument();
940
+ expect(document.querySelector('[aria-expanded]')).toBeNull();
941
+ expect(document.querySelector('[inert]')).toBeNull();
942
+ });
943
+
944
+ it('renders the title as a toggle button named by the title', () => {
945
+ render(
946
+ <SideNavSection title="Main" collapsible>
947
+ {items}
948
+ </SideNavSection>,
949
+ );
950
+ const button = screen.getByRole('button', {name: 'Main'});
951
+ expect(button).toHaveAttribute('type', 'button');
952
+ expect(button).toHaveAttribute('aria-expanded', 'true');
953
+ expect(button).not.toHaveAttribute('aria-label');
954
+ const region = getItemsRegion(button);
955
+ expect(region).toContainElement(screen.getByText('Dashboard'));
956
+ expect(region).not.toHaveAttribute('inert');
957
+ expect(region).not.toHaveAttribute('aria-hidden');
958
+ });
959
+
960
+ it('keeps role="group" labelled by the title', () => {
961
+ render(
962
+ <SideNavSection title="Main" collapsible>
963
+ {items}
964
+ </SideNavSection>,
965
+ );
966
+ const group = screen.getByRole('group');
967
+ const label = document.getElementById(
968
+ group.getAttribute('aria-labelledby')!,
969
+ );
970
+ expect(label).toHaveTextContent('Main');
971
+ });
972
+
973
+ it('toggles aria-expanded, inert and aria-hidden on click', async () => {
974
+ const user = userEvent.setup();
975
+ render(
976
+ <SideNavSection title="Main" collapsible data-testid="section">
977
+ {items}
978
+ </SideNavSection>,
979
+ );
980
+ const button = screen.getByRole('button', {name: 'Main'});
981
+ const region = getItemsRegion(button);
982
+
983
+ await user.click(button);
984
+ expect(button).toHaveAttribute('aria-expanded', 'false');
985
+ expect(region).toHaveAttribute('inert');
986
+ expect(region).toHaveAttribute('aria-hidden', 'true');
987
+ expect(screen.getByTestId('section')).toHaveAttribute(
988
+ 'data-collapsed',
989
+ 'collapsed',
990
+ );
991
+
992
+ await user.click(button);
993
+ expect(button).toHaveAttribute('aria-expanded', 'true');
994
+ expect(region).not.toHaveAttribute('inert');
995
+ expect(region).not.toHaveAttribute('aria-hidden');
996
+ expect(screen.getByTestId('section')).not.toHaveAttribute('data-collapsed');
997
+ });
998
+
999
+ it('starts collapsed with defaultIsCollapsed', () => {
1000
+ render(
1001
+ <SideNavSection title="Main" collapsible={{defaultIsCollapsed: true}}>
1002
+ {items}
1003
+ </SideNavSection>,
1004
+ );
1005
+ const button = screen.getByRole('button', {name: 'Main'});
1006
+ expect(button).toHaveAttribute('aria-expanded', 'false');
1007
+ expect(getItemsRegion(button)).toHaveAttribute('inert');
1008
+ });
1009
+
1010
+ it('controlled mode calls onCollapsedChange and respects the prop', async () => {
1011
+ const user = userEvent.setup();
1012
+ const onCollapsedChange = vi.fn();
1013
+ const {rerender} = render(
1014
+ <SideNavSection
1015
+ title="Main"
1016
+ collapsible={{isCollapsed: false, onCollapsedChange}}>
1017
+ {items}
1018
+ </SideNavSection>,
1019
+ );
1020
+ const button = screen.getByRole('button', {name: 'Main'});
1021
+
1022
+ await user.click(button);
1023
+ expect(onCollapsedChange).toHaveBeenCalledWith(true);
1024
+ // Prop did not change, so the section stays expanded
1025
+ expect(button).toHaveAttribute('aria-expanded', 'true');
1026
+
1027
+ rerender(
1028
+ <SideNavSection
1029
+ title="Main"
1030
+ collapsible={{isCollapsed: true, onCollapsedChange}}>
1031
+ {items}
1032
+ </SideNavSection>,
1033
+ );
1034
+ expect(button).toHaveAttribute('aria-expanded', 'false');
1035
+ expect(getItemsRegion(button)).toHaveAttribute('inert');
1036
+
1037
+ await user.click(button);
1038
+ expect(onCollapsedChange).toHaveBeenLastCalledWith(false);
1039
+ });
1040
+
1041
+ it('toggles with Enter and Space (native button)', async () => {
1042
+ const user = userEvent.setup();
1043
+ render(
1044
+ <SideNavSection title="Main" collapsible>
1045
+ {items}
1046
+ </SideNavSection>,
1047
+ );
1048
+ const button = screen.getByRole('button', {name: 'Main'});
1049
+ button.focus();
1050
+
1051
+ await user.keyboard('{Enter}');
1052
+ expect(button).toHaveAttribute('aria-expanded', 'false');
1053
+ await user.keyboard(' ');
1054
+ expect(button).toHaveAttribute('aria-expanded', 'true');
1055
+ });
1056
+
1057
+ it('keeps endContent outside the toggle button', () => {
1058
+ render(
1059
+ <SideNavSection
1060
+ title="Main"
1061
+ collapsible
1062
+ endContent={
1063
+ <button type="button" data-testid="section-action">
1064
+ +
1065
+ </button>
1066
+ }>
1067
+ {items}
1068
+ </SideNavSection>,
1069
+ );
1070
+ const toggle = screen.getByRole('button', {name: 'Main'});
1071
+ const action = screen.getByTestId('section-action');
1072
+ expect(toggle).not.toContainElement(action);
1073
+ expect(action.closest('button')).toBe(action);
1074
+ });
1075
+
1076
+ it('is ignored when the SideNav is rail-collapsed', () => {
1077
+ render(
1078
+ <SideNavCollapseContext
1079
+ value={{isCollapsed: true, toggle: () => {}, isCollapsible: true}}>
1080
+ <SideNavSection title="Main" collapsible={{defaultIsCollapsed: true}}>
1081
+ <SideNavItem label="Dashboard" icon={StubIcon} />
1082
+ </SideNavSection>
1083
+ </SideNavCollapseContext>,
1084
+ );
1085
+ expect(document.querySelector('[aria-expanded]')).toBeNull();
1086
+ expect(document.querySelector('[inert]')).toBeNull();
1087
+ expect(screen.getByLabelText('Dashboard')).toBeInTheDocument();
1088
+ });
1089
+
1090
+ it('is ignored when the header is hidden', () => {
1091
+ render(
1092
+ <SideNavSection
1093
+ title="Main"
1094
+ isHeaderHidden
1095
+ collapsible={{defaultIsCollapsed: true}}>
1096
+ {items}
1097
+ </SideNavSection>,
1098
+ );
1099
+ expect(document.querySelector('[aria-expanded]')).toBeNull();
1100
+ expect(document.querySelector('[inert]')).toBeNull();
1101
+ });
1102
+ });
1103
+
921
1104
  // =============================================================================
922
1105
  // Resizable
923
1106
  // =============================================================================
@@ -59,6 +59,12 @@ export const docs = {
59
59
  description: 'Visually hides the section header while keeping it accessible to screen readers.',
60
60
  default: 'false',
61
61
  },
62
+ {
63
+ name: 'collapsible',
64
+ type: 'boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void }',
65
+ description: 'Lets users collapse the section items by clicking the title. The title area becomes a toggle button (aria-expanded, chevron) and endContent stays outside it. true for uncontrolled (starts expanded), or an object with defaultIsCollapsed, or isCollapsed + onCollapsedChange for controlled mode. Ignored when isHeaderHidden is set or the SideNav is collapsed to its icon rail.',
66
+ default: 'false',
67
+ },
62
68
  {
63
69
  name: 'xstyle',
64
70
  type: 'StyleXStyles',
@@ -100,6 +106,12 @@ export const docsZh = {
100
106
  description: '视觉上隐藏分组头部,同时保持屏幕阅读器可访问。',
101
107
  default: 'false',
102
108
  },
109
+ {
110
+ name: 'collapsible',
111
+ type: 'boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void }',
112
+ description: '允许点击标题折叠分组项目。标题区域变为切换按钮(aria-expanded,带箭头),endContent 保持在按钮之外。true 为非受控模式(默认展开),或传入对象:defaultIsCollapsed,或 isCollapsed + onCollapsedChange 受控模式。设置 isHeaderHidden 或 SideNav 折叠为图标栏时忽略。',
113
+ default: 'false',
114
+ },
103
115
  ],
104
116
  };
105
117
 
@@ -114,6 +126,7 @@ export const docsDense = {
114
126
  children: 'Section items.',
115
127
  endContent: 'Right-side content in section header.',
116
128
  isHeaderHidden: 'Visually hides section header while keeping accessible to screen readers.',
129
+ collapsible: 'true | {defaultIsCollapsed?, isCollapsed?, onCollapsedChange?}. Title becomes toggle button collapsing items; endContent stays outside. Ignored if header hidden or nav rail-collapsed.',
117
130
  xstyle: 'StyleX styles for layout customization.',
118
131
  },
119
132
  };
@@ -18,17 +18,27 @@
18
18
  * - /packages/cli/templates/blocks/components/SideNav/ (showcase blocks)
19
19
  */
20
20
 
21
- import React, {useId, type ReactNode} from 'react';
21
+ import React, {
22
+ useCallback,
23
+ useId,
24
+ useMemo,
25
+ useState,
26
+ type ReactNode,
27
+ } from 'react';
22
28
  import * as stylex from '@stylexjs/stylex';
23
29
  import {
24
30
  colorVars,
25
31
  spacingVars,
26
32
  fontWeightVars,
27
33
  typeScaleVars,
34
+ durationVars,
35
+ easeVars,
36
+ radiusVars,
28
37
  } from '../theme/tokens.stylex';
29
38
  import {mergeProps} from '../utils';
30
39
  import type {BaseProps} from '../BaseProps';
31
40
  import {useSideNavCollapse} from './SideNavCollapseContext';
41
+ import {getIcon} from '../Icon/globalIconRegistry';
32
42
  import {themeProps} from '../utils/themeProps';
33
43
  // =============================================================================
34
44
  // Styles
@@ -84,6 +94,92 @@ const styles = stylex.create({
84
94
  flexDirection: 'column',
85
95
  gap: spacingVars['--spacing-0-5'],
86
96
  },
97
+
98
+ // ── Collapsible section ──
99
+ // The header row loses its own padding so the toggle button can own it
100
+ // (giving the hover overlay the full title area) while the text stays
101
+ // aligned exactly where the static header puts it.
102
+ headerCollapsible: {
103
+ paddingInline: 0,
104
+ paddingBlock: 0,
105
+ },
106
+ headerToggle: {
107
+ display: 'flex',
108
+ alignItems: 'center',
109
+ gap: spacingVars['--spacing-2'],
110
+ flex: 1,
111
+ minWidth: 0,
112
+ width: '100%',
113
+ paddingInline: spacingVars['--spacing-2'],
114
+ paddingBlock: spacingVars['--spacing-1'],
115
+ margin: 0,
116
+ borderWidth: 0,
117
+ borderStyle: 'none',
118
+ borderRadius: radiusVars['--radius-element'],
119
+ backgroundColor: 'transparent',
120
+ color: 'inherit',
121
+ fontFamily: 'inherit',
122
+ fontSize: 'inherit',
123
+ fontWeight: 'inherit',
124
+ lineHeight: 'inherit',
125
+ textAlign: 'start',
126
+ cursor: 'pointer',
127
+ userSelect: 'none',
128
+ ':hover': {
129
+ '@media (hover: hover)': {
130
+ backgroundColor: colorVars['--color-overlay-hover'],
131
+ },
132
+ },
133
+ ':active': {
134
+ backgroundColor: colorVars['--color-overlay-pressed'],
135
+ },
136
+ outline: {
137
+ default: 'none',
138
+ ':focus-visible': `2px solid ${colorVars['--color-accent']}`,
139
+ },
140
+ outlineOffset: {
141
+ default: '0',
142
+ ':focus-visible': '-2px',
143
+ },
144
+ },
145
+ endContentCollapsible: {
146
+ paddingInlineEnd: spacingVars['--spacing-2'],
147
+ },
148
+ expandChevron: {
149
+ display: 'inline-flex',
150
+ alignItems: 'center',
151
+ justifyContent: 'center',
152
+ width: spacingVars['--spacing-6'],
153
+ height: spacingVars['--spacing-6'],
154
+ color: colorVars['--color-text-secondary'],
155
+ transitionProperty: 'transform',
156
+ transitionDuration: {
157
+ default: durationVars['--duration-fast'],
158
+ '@media (prefers-reduced-motion: reduce)': '0s',
159
+ },
160
+ transitionTimingFunction: easeVars['--ease-standard'],
161
+ flexShrink: 0,
162
+ },
163
+ expandChevronExpanded: {
164
+ transform: 'rotate(180deg)',
165
+ },
166
+ itemsCollapsible: {
167
+ display: 'grid',
168
+ gridTemplateRows: '1fr',
169
+ transitionProperty: 'grid-template-rows',
170
+ transitionDuration: {
171
+ default: durationVars['--duration-medium'],
172
+ '@media (prefers-reduced-motion: reduce)': '0s',
173
+ },
174
+ transitionTimingFunction: easeVars['--ease-standard'],
175
+ },
176
+ itemsCollapsed: {
177
+ gridTemplateRows: '0fr',
178
+ },
179
+ itemsInner: {
180
+ overflow: 'hidden',
181
+ minHeight: 0,
182
+ },
87
183
  });
88
184
 
89
185
  // =============================================================================
@@ -114,6 +210,28 @@ export interface SideNavSectionProps extends BaseProps<HTMLDivElement> {
114
210
  * @default false
115
211
  */
116
212
  isHeaderHidden?: boolean;
213
+ /**
214
+ * Lets the user collapse the section's items by clicking its title.
215
+ * The title area becomes a toggle button (`aria-expanded`) with a chevron;
216
+ * `endContent` stays outside the button.
217
+ *
218
+ * - `true` — collapsible with defaults (starts expanded)
219
+ * - Object — controlled/configured:
220
+ * - `defaultIsCollapsed` — start collapsed (default: false)
221
+ * - `isCollapsed` + `onCollapsedChange` — controlled mode
222
+ *
223
+ * Ignored when `isHeaderHidden` is set or the SideNav itself is collapsed
224
+ * to its icon rail (items stay visible so their icons remain reachable).
225
+ *
226
+ * @default false
227
+ */
228
+ collapsible?:
229
+ | boolean
230
+ | {
231
+ defaultIsCollapsed?: boolean;
232
+ isCollapsed?: boolean;
233
+ onCollapsedChange?: (isCollapsed: boolean) => void;
234
+ };
117
235
  }
118
236
 
119
237
  // =============================================================================
@@ -141,6 +259,7 @@ export function SideNavSection({
141
259
  children,
142
260
  endContent,
143
261
  isHeaderHidden = false,
262
+ collapsible,
144
263
  xstyle,
145
264
  className,
146
265
  style,
@@ -150,23 +269,83 @@ export function SideNavSection({
150
269
  const {isCollapsed} = useSideNavCollapse();
151
270
  const id = useId();
152
271
  const titleId = `${id}-title`;
272
+ const itemsId = `${id}-items`;
273
+
274
+ // Section collapse state (mirrors SideNavItem's `collapsible`)
275
+ const collapsibleConfig = useMemo(
276
+ () => (typeof collapsible === 'object' ? collapsible : {}),
277
+ [collapsible],
278
+ );
279
+ const controlledCollapsed = collapsibleConfig.isCollapsed;
280
+ const isControlled = controlledCollapsed !== undefined;
281
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(
282
+ collapsibleConfig.defaultIsCollapsed ?? false,
283
+ );
284
+ const isSectionCollapsed = isControlled
285
+ ? controlledCollapsed
286
+ : uncontrolledCollapsed;
153
287
 
154
- const headerContent = (
288
+ const toggleSectionCollapse = useCallback(() => {
289
+ const next = !isSectionCollapsed;
290
+ if (!isControlled) {
291
+ setUncontrolledCollapsed(next);
292
+ }
293
+ collapsibleConfig.onCollapsedChange?.(next);
294
+ }, [isSectionCollapsed, isControlled, collapsibleConfig]);
295
+
296
+ const shouldHideHeader = isHeaderHidden || isCollapsed;
297
+ // Collapse only applies while the header is visible: a hidden header has
298
+ // no toggle, and the icon rail must keep every item reachable.
299
+ const isSectionCollapsible =
300
+ collapsible != null && collapsible !== false && !shouldHideHeader;
301
+ const isItemsCollapsed = isSectionCollapsible && isSectionCollapsed;
302
+
303
+ const titleContent = (
304
+ <span {...stylex.props(styles.titleContainer)}>
305
+ <span id={titleId} {...stylex.props(styles.title)}>
306
+ {title}
307
+ </span>
308
+ {subtitle && <span {...stylex.props(styles.subtitle)}>{subtitle}</span>}
309
+ </span>
310
+ );
311
+
312
+ const endContentElement = endContent && (
313
+ <span
314
+ {...stylex.props(
315
+ styles.endContent,
316
+ isSectionCollapsible && styles.endContentCollapsible,
317
+ )}>
318
+ {endContent}
319
+ </span>
320
+ );
321
+
322
+ const headerContent = isSectionCollapsible ? (
155
323
  <>
156
- <span {...stylex.props(styles.titleContainer)}>
157
- <span id={titleId} {...stylex.props(styles.title)}>
158
- {title}
324
+ <button
325
+ type="button"
326
+ onClick={toggleSectionCollapse}
327
+ aria-expanded={!isSectionCollapsed}
328
+ aria-controls={itemsId}
329
+ {...stylex.props(styles.headerToggle)}>
330
+ {titleContent}
331
+ <span
332
+ aria-hidden
333
+ {...stylex.props(
334
+ styles.expandChevron,
335
+ !isSectionCollapsed && styles.expandChevronExpanded,
336
+ )}>
337
+ {getIcon('chevronDown')}
159
338
  </span>
160
- {subtitle && <span {...stylex.props(styles.subtitle)}>{subtitle}</span>}
161
- </span>
162
- {endContent && (
163
- <span {...stylex.props(styles.endContent)}>{endContent}</span>
164
- )}
339
+ </button>
340
+ {endContentElement}
341
+ </>
342
+ ) : (
343
+ <>
344
+ {titleContent}
345
+ {endContentElement}
165
346
  </>
166
347
  );
167
348
 
168
- const shouldHideHeader = isHeaderHidden || isCollapsed;
169
-
170
349
  const visuallyHiddenStyle: React.CSSProperties = shouldHideHeader
171
350
  ? {
172
351
  position: 'absolute',
@@ -185,7 +364,12 @@ export function SideNavSection({
185
364
  <div
186
365
  ref={ref}
187
366
  {...mergeProps(
188
- themeProps('side-nav-section'),
367
+ themeProps(
368
+ 'side-nav-section',
369
+ isSectionCollapsible
370
+ ? {collapsed: isSectionCollapsed ? 'collapsed' : null}
371
+ : undefined,
372
+ ),
189
373
  stylex.props(styles.root, xstyle),
190
374
  className,
191
375
  style,
@@ -195,12 +379,33 @@ export function SideNavSection({
195
379
  aria-labelledby={titleId}
196
380
  data-testid={testId}>
197
381
  <div
198
- {...mergeProps(stylex.props(styles.header), {
199
- style: shouldHideHeader ? visuallyHiddenStyle : undefined,
200
- })}>
382
+ {...mergeProps(
383
+ stylex.props(
384
+ styles.header,
385
+ isSectionCollapsible && styles.headerCollapsible,
386
+ ),
387
+ {
388
+ style: shouldHideHeader ? visuallyHiddenStyle : undefined,
389
+ },
390
+ )}>
201
391
  {headerContent}
202
392
  </div>
203
- <div {...stylex.props(styles.items)}>{children}</div>
393
+ {isSectionCollapsible ? (
394
+ <div
395
+ id={itemsId}
396
+ aria-hidden={isItemsCollapsed || undefined}
397
+ inert={isItemsCollapsed ? true : undefined}
398
+ {...stylex.props(
399
+ styles.itemsCollapsible,
400
+ isItemsCollapsed && styles.itemsCollapsed,
401
+ )}>
402
+ <div {...stylex.props(styles.itemsInner)}>
403
+ <div {...stylex.props(styles.items)}>{children}</div>
404
+ </div>
405
+ </div>
406
+ ) : (
407
+ <div {...stylex.props(styles.items)}>{children}</div>
408
+ )}
204
409
  </div>
205
410
  );
206
411
  }