@mindlogic-ai/logician-ui 5.0.0-alpha.11 → 5.0.0-alpha.13
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/README.md +1 -0
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.js +4 -0
- package/dist/components/Input/Input.js.map +1 -1
- package/dist/components/Input/Input.mjs +4 -0
- package/dist/components/Input/Input.mjs.map +1 -1
- package/dist/components/NavList/NavList.d.ts +34 -0
- package/dist/components/NavList/NavList.d.ts.map +1 -0
- package/dist/components/NavList/NavList.js +113 -0
- package/dist/components/NavList/NavList.js.map +1 -0
- package/dist/components/NavList/NavList.mjs +111 -0
- package/dist/components/NavList/NavList.mjs.map +1 -0
- package/dist/components/NavList/NavList.styles.d.ts +90 -0
- package/dist/components/NavList/NavList.styles.d.ts.map +1 -0
- package/dist/components/NavList/NavList.styles.js +104 -0
- package/dist/components/NavList/NavList.styles.js.map +1 -0
- package/dist/components/NavList/NavList.styles.mjs +97 -0
- package/dist/components/NavList/NavList.styles.mjs.map +1 -0
- package/dist/components/NavList/NavList.translations.json.js +19 -0
- package/dist/components/NavList/NavList.translations.json.js.map +1 -0
- package/dist/components/NavList/NavList.translations.json.mjs +14 -0
- package/dist/components/NavList/NavList.translations.json.mjs.map +1 -0
- package/dist/components/NavList/NavList.types.d.ts +131 -0
- package/dist/components/NavList/NavList.types.d.ts.map +1 -0
- package/dist/components/NavList/NavListContext.d.ts +30 -0
- package/dist/components/NavList/NavListContext.d.ts.map +1 -0
- package/dist/components/NavList/NavListContext.js +16 -0
- package/dist/components/NavList/NavListContext.js.map +1 -0
- package/dist/components/NavList/NavListContext.mjs +13 -0
- package/dist/components/NavList/NavListContext.mjs.map +1 -0
- package/dist/components/NavList/NavListGroup.d.ts +31 -0
- package/dist/components/NavList/NavListGroup.d.ts.map +1 -0
- package/dist/components/NavList/NavListGroup.js +115 -0
- package/dist/components/NavList/NavListGroup.js.map +1 -0
- package/dist/components/NavList/NavListGroup.mjs +113 -0
- package/dist/components/NavList/NavListGroup.mjs.map +1 -0
- package/dist/components/NavList/NavListItem.d.ts +15 -0
- package/dist/components/NavList/NavListItem.d.ts.map +1 -0
- package/dist/components/NavList/NavListItem.js +269 -0
- package/dist/components/NavList/NavListItem.js.map +1 -0
- package/dist/components/NavList/NavListItem.mjs +267 -0
- package/dist/components/NavList/NavListItem.mjs.map +1 -0
- package/dist/components/NavList/NavListSkeleton.d.ts +17 -0
- package/dist/components/NavList/NavListSkeleton.d.ts.map +1 -0
- package/dist/components/NavList/NavListSkeleton.js +33 -0
- package/dist/components/NavList/NavListSkeleton.js.map +1 -0
- package/dist/components/NavList/NavListSkeleton.mjs +31 -0
- package/dist/components/NavList/NavListSkeleton.mjs.map +1 -0
- package/dist/components/NavList/index.d.ts +7 -0
- package/dist/components/NavList/index.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.js +4 -0
- package/dist/components/Textarea/Textarea.js.map +1 -1
- package/dist/components/Textarea/Textarea.mjs +4 -0
- package/dist/components/Textarea/Textarea.mjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -1
- package/dist/index.mjs.map +1 -1
- package/dist/utils/focusRing.d.ts +22 -0
- package/dist/utils/focusRing.d.ts.map +1 -1
- package/dist/utils/focusRing.js +22 -0
- package/dist/utils/focusRing.js.map +1 -1
- package/dist/utils/focusRing.mjs +21 -1
- package/dist/utils/focusRing.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/Input/Input.tsx +4 -0
- package/src/components/Menu/Menu.usage.mdx +8 -0
- package/src/components/NavList/NavList.stories.tsx +895 -0
- package/src/components/NavList/NavList.styles.ts +114 -0
- package/src/components/NavList/NavList.test.tsx +1254 -0
- package/src/components/NavList/NavList.translations.json +9 -0
- package/src/components/NavList/NavList.tsx +160 -0
- package/src/components/NavList/NavList.types.ts +145 -0
- package/src/components/NavList/NavList.usage.mdx +540 -0
- package/src/components/NavList/NavListContext.ts +25 -0
- package/src/components/NavList/NavListGroup.tsx +243 -0
- package/src/components/NavList/NavListItem.tsx +525 -0
- package/src/components/NavList/NavListSkeleton.tsx +57 -0
- package/src/components/NavList/index.ts +14 -0
- package/src/components/Tabs/Tabs.usage.mdx +58 -14
- package/src/components/Textarea/Textarea.tsx +4 -0
- package/src/components/Tooltip/Tooltip.usage.mdx +8 -0
- package/src/components/a11y.kwcag.test.tsx +66 -0
- package/src/components/claude.md +1 -1
- package/src/index.ts +7 -1
- package/src/utils/focusRing.ts +23 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AriaAttributes,
|
|
3
|
+
Children,
|
|
4
|
+
cloneElement,
|
|
5
|
+
forwardRef,
|
|
6
|
+
isValidElement,
|
|
7
|
+
MouseEvent,
|
|
8
|
+
MouseEventHandler,
|
|
9
|
+
ReactElement,
|
|
10
|
+
ReactNode,
|
|
11
|
+
SyntheticEvent,
|
|
12
|
+
useEffect,
|
|
13
|
+
useId,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import {
|
|
18
|
+
Box,
|
|
19
|
+
chakra,
|
|
20
|
+
LocaleProvider,
|
|
21
|
+
useLocaleContext,
|
|
22
|
+
VisuallyHidden,
|
|
23
|
+
} from '@chakra-ui/react';
|
|
24
|
+
|
|
25
|
+
import { Tooltip } from '@/components/Tooltip';
|
|
26
|
+
import { useTranslate } from '@/hooks/useTranslate';
|
|
27
|
+
import { disabledText } from '@/utils/disabledControl';
|
|
28
|
+
import { focusRingInset } from '@/utils/focusRing';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
byOrientation,
|
|
32
|
+
inheritDirection,
|
|
33
|
+
localeFor,
|
|
34
|
+
NAV_LIST_RAMP,
|
|
35
|
+
pressedFill,
|
|
36
|
+
} from './NavList.styles';
|
|
37
|
+
import NavListTranslations from './NavList.translations.json';
|
|
38
|
+
import { NavListItemProps } from './NavList.types';
|
|
39
|
+
import { useNavListContext } from './NavListContext';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One row: a link (`href`, or `asChild` around a router link) or, with only
|
|
43
|
+
* `onClick`, a `<button type="button">`.
|
|
44
|
+
*
|
|
45
|
+
* Both are native, so activation is the platform's: Enter follows a link,
|
|
46
|
+
* Enter and Space press a button, and Tab moves between rows. There is no
|
|
47
|
+
* arrow-key handling on purpose — a rail of links is not a composite widget,
|
|
48
|
+
* and roving focus would hide every row but one from the tab order.
|
|
49
|
+
*/
|
|
50
|
+
type RouterChildProps = {
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
onClick?: MouseEventHandler<HTMLElement>;
|
|
53
|
+
target?: string;
|
|
54
|
+
'aria-current'?: AriaAttributes['aria-current'];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The label span is cut off by its ellipsis. */
|
|
58
|
+
// Every selector below names the row by this attribute, not by `data-part`:
|
|
59
|
+
// the tooltip trigger's own `data-part="trigger"` lands on the same element.
|
|
60
|
+
const ROW = '[data-nav-list-row]';
|
|
61
|
+
const ACTION = '[data-part="trailing-action"]';
|
|
62
|
+
/** Touch screens: no hover to reveal on, and a finger needs the space kept. */
|
|
63
|
+
const NO_HOVER = '@media (hover: none), (pointer: coarse)';
|
|
64
|
+
/** The row, not the action beside it, is being pressed. */
|
|
65
|
+
const PRESSED = `&:has(${ROW}:active)`;
|
|
66
|
+
|
|
67
|
+
const isTruncated = (el: HTMLElement | null) =>
|
|
68
|
+
el != null && el.scrollWidth > el.clientWidth + 1;
|
|
69
|
+
|
|
70
|
+
export const NavListItem = forwardRef<HTMLElement, NavListItemProps>(
|
|
71
|
+
(
|
|
72
|
+
{
|
|
73
|
+
children,
|
|
74
|
+
href,
|
|
75
|
+
asChild = false,
|
|
76
|
+
onClick,
|
|
77
|
+
isCurrent = false,
|
|
78
|
+
isDisabled = false,
|
|
79
|
+
leadingVisual,
|
|
80
|
+
trailingVisual,
|
|
81
|
+
trailingAction,
|
|
82
|
+
trailingActionVisibility = 'always',
|
|
83
|
+
tooltip,
|
|
84
|
+
...rest
|
|
85
|
+
},
|
|
86
|
+
ref
|
|
87
|
+
) => {
|
|
88
|
+
const { orientation, isCollapsed, tooltipDir, revealCurrent } =
|
|
89
|
+
useNavListContext();
|
|
90
|
+
const translate = useTranslate(NavListTranslations);
|
|
91
|
+
const labelRef = useRef<HTMLSpanElement>(null);
|
|
92
|
+
const descriptionId = useId();
|
|
93
|
+
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (isCurrent) revealCurrent();
|
|
97
|
+
}, [isCurrent, revealCurrent]);
|
|
98
|
+
const isLink = asChild || href != null;
|
|
99
|
+
|
|
100
|
+
const child = isLink && asChild ? Children.only(children) : null;
|
|
101
|
+
if (asChild && !isValidElement<RouterChildProps>(child)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'NavList.Item with asChild needs exactly one element child'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const routerChild = child as ReactElement<RouterChildProps> | null;
|
|
107
|
+
const label = routerChild ? routerChild.props.children : children;
|
|
108
|
+
const opensNewWindow =
|
|
109
|
+
!isDisabled &&
|
|
110
|
+
isLink &&
|
|
111
|
+
(routerChild?.props.target ?? (rest as { target?: string }).target) ===
|
|
112
|
+
'_blank';
|
|
113
|
+
|
|
114
|
+
const inherited = useLocaleContext();
|
|
115
|
+
const content = (
|
|
116
|
+
<LocaleProvider locale={inherited.locale}>
|
|
117
|
+
{leadingVisual != null && (
|
|
118
|
+
<chakra.span
|
|
119
|
+
aria-hidden
|
|
120
|
+
display="inline-flex"
|
|
121
|
+
alignItems="center"
|
|
122
|
+
flexShrink={0}
|
|
123
|
+
>
|
|
124
|
+
{leadingVisual}
|
|
125
|
+
</chakra.span>
|
|
126
|
+
)}
|
|
127
|
+
{/* One label element in both modes, so toggling the rail fades it
|
|
128
|
+
instead of swapping it: collapsed it is transparent and 1px wide —
|
|
129
|
+
the size VisuallyHidden uses, which every screen reader still
|
|
130
|
+
reads — and stays the row's accessible name. */}
|
|
131
|
+
<chakra.span
|
|
132
|
+
ref={labelRef}
|
|
133
|
+
data-part="label"
|
|
134
|
+
flex={byOrientation(orientation, '1 1 auto', '0 0 auto')}
|
|
135
|
+
minW={isCollapsed ? '1px' : 0}
|
|
136
|
+
overflow="hidden"
|
|
137
|
+
textOverflow="ellipsis"
|
|
138
|
+
whiteSpace="nowrap"
|
|
139
|
+
opacity={isCollapsed ? 0 : 1}
|
|
140
|
+
animationStyle="feedback"
|
|
141
|
+
transitionProperty="opacity"
|
|
142
|
+
>
|
|
143
|
+
{label}
|
|
144
|
+
</chakra.span>
|
|
145
|
+
{/* KWCAG 2.1 — a link that opens a new window says so in its name. */}
|
|
146
|
+
{opensNewWindow && (
|
|
147
|
+
<VisuallyHidden>{` (${translate('nav_list_opens_new_window')})`}</VisuallyHidden>
|
|
148
|
+
)}
|
|
149
|
+
{trailingVisual != null && (
|
|
150
|
+
<chakra.span
|
|
151
|
+
display="inline-flex"
|
|
152
|
+
alignItems="center"
|
|
153
|
+
flexShrink={0}
|
|
154
|
+
ms="auto"
|
|
155
|
+
color={isDisabled ? 'disabled.fg' : NAV_LIST_RAMP.trailing}
|
|
156
|
+
fontWeight="normal"
|
|
157
|
+
fontVariantNumeric="tabular-nums"
|
|
158
|
+
// Fades with the label. `visibility` flips at the END of the fade
|
|
159
|
+
// (it is discrete), which is when the count leaves the row's name.
|
|
160
|
+
opacity={isCollapsed ? 0 : 1}
|
|
161
|
+
visibility={isCollapsed ? 'hidden' : 'visible'}
|
|
162
|
+
animationStyle="feedback"
|
|
163
|
+
transitionProperty="opacity, visibility"
|
|
164
|
+
>
|
|
165
|
+
{trailingVisual}
|
|
166
|
+
</chakra.span>
|
|
167
|
+
)}
|
|
168
|
+
</LocaleProvider>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// One tooltip per row, three jobs. Collapsed: the label, beside the icon.
|
|
172
|
+
// `tooltip`: the caller's description. Otherwise: the label again, only
|
|
173
|
+
// while the ellipsis is actually cutting it off.
|
|
174
|
+
const tooltipKind = isCollapsed
|
|
175
|
+
? 'label'
|
|
176
|
+
: tooltip != null
|
|
177
|
+
? 'description'
|
|
178
|
+
: 'overflow';
|
|
179
|
+
|
|
180
|
+
// The overflow tooltip is gated at the trigger: zag skips opening when the
|
|
181
|
+
// event that would open it was `defaultPrevented`, and the row's own
|
|
182
|
+
// handlers run before zag's. A controlled `open` cannot do this — zag
|
|
183
|
+
// enters its open state first and only re-syncs when the prop CHANGES.
|
|
184
|
+
const restHandlers = rest as {
|
|
185
|
+
onPointerMove?: (e: SyntheticEvent) => void;
|
|
186
|
+
onPointerOver?: (e: SyntheticEvent) => void;
|
|
187
|
+
onFocus?: (e: SyntheticEvent) => void;
|
|
188
|
+
};
|
|
189
|
+
// A POINTER also waits for the label to finish fading when the rail is
|
|
190
|
+
// toggled, so no tooltip flashes over a row that is still changing shape:
|
|
191
|
+
// the label one only once the label is fully gone, the others only once
|
|
192
|
+
// it is fully back. The next pointermove after the fade opens it. Focus
|
|
193
|
+
// is never held back — a cancelled focus is not re-fired, so a row
|
|
194
|
+
// focused mid-fade would never show its label.
|
|
195
|
+
const isSettled = () => {
|
|
196
|
+
const el = labelRef.current;
|
|
197
|
+
if (!el) return true;
|
|
198
|
+
const opacity = parseFloat(getComputedStyle(el).opacity);
|
|
199
|
+
if (Number.isNaN(opacity)) return true;
|
|
200
|
+
return isCollapsed ? opacity === 0 : opacity === 1;
|
|
201
|
+
};
|
|
202
|
+
const gate =
|
|
203
|
+
(handler?: (e: SyntheticEvent) => void, waitForFade = true) =>
|
|
204
|
+
(event: SyntheticEvent) => {
|
|
205
|
+
if (
|
|
206
|
+
(waitForFade && !isSettled()) ||
|
|
207
|
+
(tooltipKind === 'overflow' && !isTruncated(labelRef.current))
|
|
208
|
+
)
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
handler?.(event);
|
|
211
|
+
};
|
|
212
|
+
const tooltipGate = {
|
|
213
|
+
onPointerMove: gate(restHandlers.onPointerMove),
|
|
214
|
+
onPointerOver: gate(restHandlers.onPointerOver),
|
|
215
|
+
onFocus: gate(restHandlers.onFocus, false),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// The description is a visually hidden element of its own, referenced
|
|
219
|
+
// all the time — not the tooltip, which zag only references while it is
|
|
220
|
+
// open. A disabled row cannot take focus, so a description that existed
|
|
221
|
+
// only while open would never reach a keyboard or screen-reader user,
|
|
222
|
+
// which is exactly when "why is this disabled" matters. The tooltip
|
|
223
|
+
// itself is then never a description (`aria-label` on its root takes its
|
|
224
|
+
// id away), and while it is open the row's own value — the caller's ids,
|
|
225
|
+
// or an empty list — outranks the dangling id zag would leave behind.
|
|
226
|
+
const callerDescribedBy = (rest as { 'aria-describedby'?: string })[
|
|
227
|
+
'aria-describedby'
|
|
228
|
+
];
|
|
229
|
+
const describedBy =
|
|
230
|
+
[callerDescribedBy, tooltip != null ? descriptionId : undefined]
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.join(' ') || (isTooltipOpen ? '' : undefined);
|
|
233
|
+
const describedByProps = { 'aria-describedby': describedBy };
|
|
234
|
+
|
|
235
|
+
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
|
236
|
+
// Belt and braces: a disabled `<button>` fires no click and a disabled
|
|
237
|
+
// link has no `href`, so this only guards a caller who forces one.
|
|
238
|
+
if (isDisabled) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
onClick?.(event);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// `hover`: the action floats over the end of the label instead of keeping
|
|
246
|
+
// its own column, so a resting row spends its full width on the label —
|
|
247
|
+
// the app's HoverRevealedKebab. The row and the action share one grid
|
|
248
|
+
// cell's row; the row spans both columns at rest and gives the second one
|
|
249
|
+
// back to the action when it shows. Only the label's end moves: the row
|
|
250
|
+
// is as tall as it ever was, so nothing below it shifts. Vertical only —
|
|
251
|
+
// a horizontal row is as wide as its label, so there is nothing to give.
|
|
252
|
+
const overlaysAction =
|
|
253
|
+
trailingActionVisibility === 'hover' &&
|
|
254
|
+
trailingAction != null &&
|
|
255
|
+
!isCollapsed;
|
|
256
|
+
|
|
257
|
+
// The fill lives on the `<li>`, not the row, so a trailing action sits on
|
|
258
|
+
// the same surface: hovering the action keeps the row lit, and the current
|
|
259
|
+
// row's fill runs under its action instead of stopping short of it.
|
|
260
|
+
const rowStyles = {
|
|
261
|
+
display: 'flex',
|
|
262
|
+
alignItems: 'center',
|
|
263
|
+
gap: 2,
|
|
264
|
+
flex: byOrientation(orientation, '1 1 auto', '0 0 auto'),
|
|
265
|
+
minW: 0,
|
|
266
|
+
// The same inline padding and start alignment in both modes, so the
|
|
267
|
+
// icon does not move when the rail is toggled: at 2.5 a row is the
|
|
268
|
+
// 40px square around an `sm` icon. The `<li>` is what narrows.
|
|
269
|
+
minH: isCollapsed ? 10 : 9,
|
|
270
|
+
px: 2.5,
|
|
271
|
+
py: 2,
|
|
272
|
+
// Clips the label and count while the rail narrows around them. Only
|
|
273
|
+
// collapsed, so an expanded row never clips a badge or a dot that a
|
|
274
|
+
// visual draws past its own box.
|
|
275
|
+
overflow: isCollapsed ? 'hidden' : undefined,
|
|
276
|
+
borderRadius: 'md',
|
|
277
|
+
border: 'none',
|
|
278
|
+
bg: 'transparent',
|
|
279
|
+
textAlign: 'start',
|
|
280
|
+
textDecoration: 'none',
|
|
281
|
+
textStyle: 'p',
|
|
282
|
+
cursor: 'pointer',
|
|
283
|
+
color: isCurrent ? NAV_LIST_RAMP.currentLabel : NAV_LIST_RAMP.label,
|
|
284
|
+
fontWeight: isCurrent ? 'bold' : 'normal',
|
|
285
|
+
_hover: { textDecoration: 'none' },
|
|
286
|
+
_disabled: { ...disabledText, cursor: 'not-allowed' },
|
|
287
|
+
...inheritDirection,
|
|
288
|
+
...(overlaysAction && { gridRow: 1, gridColumn: '1 / -1' }),
|
|
289
|
+
// Inset: rows sit 2px apart and a horizontal rail scrolls, so the
|
|
290
|
+
// outward ring would land on the neighbour or be clipped.
|
|
291
|
+
...focusRingInset,
|
|
292
|
+
} as const;
|
|
293
|
+
|
|
294
|
+
// Both state attributes go on the element that carries the role, and on
|
|
295
|
+
// the router child's own props when there is one (its props win the
|
|
296
|
+
// `asChild` merge). `data-current` is the hook for scroll-to-current.
|
|
297
|
+
const stateProps = {
|
|
298
|
+
'aria-current': isCurrent ? (isLink ? 'page' : 'true') : undefined,
|
|
299
|
+
'data-current': isCurrent ? '' : undefined,
|
|
300
|
+
'data-nav-list-row': '',
|
|
301
|
+
} as const;
|
|
302
|
+
|
|
303
|
+
let row: ReactElement;
|
|
304
|
+
if (!isLink) {
|
|
305
|
+
row = (
|
|
306
|
+
<chakra.button
|
|
307
|
+
ref={ref as React.Ref<HTMLButtonElement>}
|
|
308
|
+
type="button"
|
|
309
|
+
disabled={isDisabled}
|
|
310
|
+
{...stateProps}
|
|
311
|
+
onClick={handleClick}
|
|
312
|
+
{...rowStyles}
|
|
313
|
+
// The prop type is the anchor's; the global attributes and style
|
|
314
|
+
// props it carries are the same on a button.
|
|
315
|
+
{...(rest as unknown as React.ComponentProps<typeof chakra.button>)}
|
|
316
|
+
{...describedByProps}
|
|
317
|
+
{...tooltipGate}
|
|
318
|
+
>
|
|
319
|
+
{content}
|
|
320
|
+
</chakra.button>
|
|
321
|
+
);
|
|
322
|
+
} else {
|
|
323
|
+
const linkProps = {
|
|
324
|
+
ref: ref as React.Ref<HTMLAnchorElement>,
|
|
325
|
+
...rowStyles,
|
|
326
|
+
...rest,
|
|
327
|
+
...describedByProps,
|
|
328
|
+
...tooltipGate,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (isDisabled) {
|
|
332
|
+
// A disabled link has no destination, so it is not rendered as one —
|
|
333
|
+
// not even the router link, which would keep its href for middle-click,
|
|
334
|
+
// "open in new tab" and prefetch. Still announced as a link, with
|
|
335
|
+
// `aria-disabled`, the same way whether or not `asChild` was used. With
|
|
336
|
+
// no `href` it is out of the tab order.
|
|
337
|
+
row = (
|
|
338
|
+
<chakra.a
|
|
339
|
+
role="link"
|
|
340
|
+
aria-disabled="true"
|
|
341
|
+
{...stateProps}
|
|
342
|
+
{...linkProps}
|
|
343
|
+
target={undefined}
|
|
344
|
+
>
|
|
345
|
+
{content}
|
|
346
|
+
</chakra.a>
|
|
347
|
+
);
|
|
348
|
+
} else if (routerChild) {
|
|
349
|
+
// Our state goes on the CHILD's props, not on the slot: Chakra's
|
|
350
|
+
// `asChild` merge lets the child's own props win, so a router NavLink
|
|
351
|
+
// that sets its own `aria-current` would otherwise contradict
|
|
352
|
+
// `isCurrent`. The child's own click handler still runs, after ours.
|
|
353
|
+
row = (
|
|
354
|
+
<chakra.a asChild {...linkProps}>
|
|
355
|
+
{cloneElement(
|
|
356
|
+
routerChild,
|
|
357
|
+
{
|
|
358
|
+
...stateProps,
|
|
359
|
+
...describedByProps,
|
|
360
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
361
|
+
handleClick(event);
|
|
362
|
+
routerChild.props.onClick?.(event);
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
content
|
|
366
|
+
)}
|
|
367
|
+
</chakra.a>
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
row = (
|
|
371
|
+
<chakra.a
|
|
372
|
+
href={href}
|
|
373
|
+
{...stateProps}
|
|
374
|
+
onClick={handleClick}
|
|
375
|
+
{...linkProps}
|
|
376
|
+
>
|
|
377
|
+
{content}
|
|
378
|
+
</chakra.a>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The two label tooltips name the tooltip itself (`aria-label`), which
|
|
384
|
+
// takes it out of the trigger's description — the row is already called that.
|
|
385
|
+
const tooltipContent =
|
|
386
|
+
tooltipKind === 'description' ? (
|
|
387
|
+
tooltip
|
|
388
|
+
) : isCollapsed && tooltip != null ? (
|
|
389
|
+
<>
|
|
390
|
+
{label}
|
|
391
|
+
<Box>{tooltip}</Box>
|
|
392
|
+
</>
|
|
393
|
+
) : (
|
|
394
|
+
label
|
|
395
|
+
);
|
|
396
|
+
const labelText =
|
|
397
|
+
typeof label === 'string' || typeof label === 'number'
|
|
398
|
+
? String(label)
|
|
399
|
+
: undefined;
|
|
400
|
+
const withTooltip = (
|
|
401
|
+
<Tooltip
|
|
402
|
+
content={tooltipContent}
|
|
403
|
+
// Beside the icon, towards the content: the rail is on the right of
|
|
404
|
+
// a right-to-left page. Popper placements are physical.
|
|
405
|
+
placement={
|
|
406
|
+
isCollapsed ? (tooltipDir === 'rtl' ? 'left' : 'right') : 'top'
|
|
407
|
+
}
|
|
408
|
+
onOpenChange={({ open }) => setIsTooltipOpen(open)}
|
|
409
|
+
// Zag only checks that this is set — it takes the content's id and
|
|
410
|
+
// role away, so the trigger is never described by the tooltip. Not
|
|
411
|
+
// rendered anywhere. The description, if any, is `descriptionId`.
|
|
412
|
+
aria-label={labelText ?? 'tooltip'}
|
|
413
|
+
contentProps={{ 'aria-hidden': true }}
|
|
414
|
+
>
|
|
415
|
+
{row}
|
|
416
|
+
</Tooltip>
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const fills = isDisabled
|
|
420
|
+
? undefined
|
|
421
|
+
: {
|
|
422
|
+
// On the `<li>`, like the hover fill, so the press covers the
|
|
423
|
+
// action's end of the row too — but only a press on the ROW: a
|
|
424
|
+
// press on the action is the action's, and lights its own button.
|
|
425
|
+
// No `>` inside `:has()`: jsdom's selector engine throws on a
|
|
426
|
+
// relative selector, which would break every consumer's tests.
|
|
427
|
+
[PRESSED]: pressedFill,
|
|
428
|
+
};
|
|
429
|
+
// Shown: hovered, focus anywhere in the row, or the action's menu open.
|
|
430
|
+
const shown = [`&:hover > ${ACTION}`, `&:focus-within > ${ACTION}`].join(
|
|
431
|
+
', '
|
|
432
|
+
);
|
|
433
|
+
const revealOnHover =
|
|
434
|
+
trailingActionVisibility === 'hover'
|
|
435
|
+
? {
|
|
436
|
+
[`& > ${ACTION}`]: { opacity: 0 },
|
|
437
|
+
[shown]: { opacity: 1 },
|
|
438
|
+
// Its own rule: `:has()` is not forgiving, and a browser without
|
|
439
|
+
// it would drop every selector listed beside it.
|
|
440
|
+
[`& > ${ACTION}:has([aria-expanded="true"], [data-state="open"])`]:
|
|
441
|
+
{ opacity: 1 },
|
|
442
|
+
// Touch: always shown, and with its space kept.
|
|
443
|
+
[NO_HOVER]: {
|
|
444
|
+
[`& > ${ACTION}`]: { opacity: 1 },
|
|
445
|
+
...(overlaysAction && { [`& > ${ROW}`]: { gridColumn: 1 } }),
|
|
446
|
+
},
|
|
447
|
+
...(overlaysAction && {
|
|
448
|
+
// The row gives the action its column back exactly when the
|
|
449
|
+
// action shows, so the label ends before it.
|
|
450
|
+
[`&:hover > ${ROW}, &:focus-within > ${ROW}`]: { gridColumn: 1 },
|
|
451
|
+
[`&:has(${ACTION} [aria-expanded="true"], ${ACTION} [data-state="open"]) > ${ROW}`]:
|
|
452
|
+
{ gridColumn: 1 },
|
|
453
|
+
}),
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<chakra.li
|
|
459
|
+
display={
|
|
460
|
+
overlaysAction ? byOrientation(orientation, 'grid', 'flex') : 'flex'
|
|
461
|
+
}
|
|
462
|
+
gridTemplateColumns={overlaysAction ? 'minmax(0, 1fr) auto' : undefined}
|
|
463
|
+
alignItems="center"
|
|
464
|
+
gap={0.5}
|
|
465
|
+
flexShrink={0}
|
|
466
|
+
minW={0}
|
|
467
|
+
// Narrows to the square when the rail collapses, on the label's clock,
|
|
468
|
+
// so the fill shrinks with the rail instead of snapping to 40px.
|
|
469
|
+
maxW={isCollapsed ? 10 : byOrientation(orientation, '100%', 'none')}
|
|
470
|
+
animationStyle="feedback"
|
|
471
|
+
transitionProperty="max-width"
|
|
472
|
+
borderRadius="md"
|
|
473
|
+
bg={isCurrent ? NAV_LIST_RAMP.currentFill : 'transparent'}
|
|
474
|
+
_hover={
|
|
475
|
+
isDisabled
|
|
476
|
+
? undefined
|
|
477
|
+
: {
|
|
478
|
+
bg: NAV_LIST_RAMP.hoverFill,
|
|
479
|
+
// Again inside hover: Chakra's hover rule is as specific as the
|
|
480
|
+
// press rule and comes later, so a pointer press would lose.
|
|
481
|
+
[PRESSED]: pressedFill,
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
css={{ ...fills, ...revealOnHover }}
|
|
485
|
+
>
|
|
486
|
+
{/* Zag stamps `dir` on the portalled tooltip from Ark's locale (`ltr`
|
|
487
|
+
unless a LocaleProvider says otherwise), so a tooltip in an RTL
|
|
488
|
+
rail would set its text left to right. The list measures the
|
|
489
|
+
direction after mount, and a tooltip only opens after that. The
|
|
490
|
+
row lays out from CSS (`inheritDirection`), never from this; its
|
|
491
|
+
content gets the caller's own locale back. */}
|
|
492
|
+
<LocaleProvider locale={localeFor(inherited, tooltipDir)}>
|
|
493
|
+
{withTooltip}
|
|
494
|
+
</LocaleProvider>
|
|
495
|
+
{tooltip != null && (
|
|
496
|
+
// `hidden`, not visually hidden: a description reference still
|
|
497
|
+
// reads hidden text, and browse mode does not read it a second time.
|
|
498
|
+
<chakra.span id={descriptionId} hidden>
|
|
499
|
+
{tooltip}
|
|
500
|
+
</chakra.span>
|
|
501
|
+
)}
|
|
502
|
+
{trailingAction != null && !isCollapsed && (
|
|
503
|
+
<chakra.span
|
|
504
|
+
data-part="trailing-action"
|
|
505
|
+
display="inline-flex"
|
|
506
|
+
alignItems="center"
|
|
507
|
+
flexShrink={0}
|
|
508
|
+
pe={0.5}
|
|
509
|
+
// Over the row's end, above its fill and its focus ring.
|
|
510
|
+
{...(overlaysAction && {
|
|
511
|
+
gridRow: 1,
|
|
512
|
+
gridColumn: 2,
|
|
513
|
+
position: 'relative',
|
|
514
|
+
zIndex: 1,
|
|
515
|
+
})}
|
|
516
|
+
>
|
|
517
|
+
{trailingAction}
|
|
518
|
+
</chakra.span>
|
|
519
|
+
)}
|
|
520
|
+
</chakra.li>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
NavListItem.displayName = 'NavList.Item';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { chakra, Skeleton } from '@chakra-ui/react';
|
|
3
|
+
|
|
4
|
+
import { byOrientation, listStyles } from './NavList.styles';
|
|
5
|
+
import { NavListSkeletonProps } from './NavList.types';
|
|
6
|
+
|
|
7
|
+
/** Widths that vary row to row, so the placeholder does not read as a table. */
|
|
8
|
+
const WIDTHS = ['72%', '56%', '84%', '64%', '48%', '76%'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Placeholder rows while a rail's entries load, with the same row height,
|
|
12
|
+
* radius and gap as the real list, so nothing moves when it swaps in.
|
|
13
|
+
*
|
|
14
|
+
* Render it INSTEAD of the `NavList`, not inside it. It is `aria-hidden`: a
|
|
15
|
+
* column of grey bars says nothing to a screen reader, and "loading" belongs
|
|
16
|
+
* to the region that is loading (`aria-busy`, or a status message) rather
|
|
17
|
+
* than to one of its placeholders.
|
|
18
|
+
*
|
|
19
|
+
* Counts that are still loading are not this — leave `trailingVisual` unset
|
|
20
|
+
* until they arrive, and the row keeps its label.
|
|
21
|
+
*/
|
|
22
|
+
export const NavListSkeleton = forwardRef<
|
|
23
|
+
HTMLUListElement,
|
|
24
|
+
NavListSkeletonProps
|
|
25
|
+
>(({ count = 5, orientation = 'vertical', ...rest }, ref) => (
|
|
26
|
+
<chakra.ul ref={ref} aria-hidden {...listStyles(orientation)} {...rest}>
|
|
27
|
+
{Array.from({ length: count }, (_, i) => (
|
|
28
|
+
<chakra.li
|
|
29
|
+
key={i}
|
|
30
|
+
display="flex"
|
|
31
|
+
alignItems="center"
|
|
32
|
+
flexShrink={0}
|
|
33
|
+
// The row's own box: `minH`, padding and one line of `p` text, so
|
|
34
|
+
// the placeholder is exactly as tall as the row that replaces it.
|
|
35
|
+
minH={9}
|
|
36
|
+
px={2.5}
|
|
37
|
+
py={2}
|
|
38
|
+
textStyle="p"
|
|
39
|
+
w={byOrientation(orientation, 'auto', '96px')}
|
|
40
|
+
>
|
|
41
|
+
<Skeleton
|
|
42
|
+
h={4}
|
|
43
|
+
w={byOrientation(orientation, WIDTHS[i % WIDTHS.length], 'full')}
|
|
44
|
+
borderRadius="sm"
|
|
45
|
+
// A placeholder that stops pulsing is still a placeholder; there
|
|
46
|
+
// is no progress for the motion to carry.
|
|
47
|
+
_motionReduce={{ animation: 'none' }}
|
|
48
|
+
/>
|
|
49
|
+
<chakra.span w={0} overflow="hidden">
|
|
50
|
+
{'\u00A0'}
|
|
51
|
+
</chakra.span>
|
|
52
|
+
</chakra.li>
|
|
53
|
+
))}
|
|
54
|
+
</chakra.ul>
|
|
55
|
+
));
|
|
56
|
+
|
|
57
|
+
NavListSkeleton.displayName = 'NavList.Skeleton';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { NavList } from './NavList';
|
|
2
|
+
export { NAV_LIST_RAMP } from './NavList.styles';
|
|
3
|
+
export type {
|
|
4
|
+
NavListGroupProps,
|
|
5
|
+
NavListItemProps,
|
|
6
|
+
NavListOrientation,
|
|
7
|
+
NavListProps,
|
|
8
|
+
NavListResponsiveOrientation,
|
|
9
|
+
NavListSkeletonProps,
|
|
10
|
+
NavListTrailingActionVisibility,
|
|
11
|
+
} from './NavList.types';
|
|
12
|
+
export { NavListGroup } from './NavListGroup';
|
|
13
|
+
export { NavListItem } from './NavListItem';
|
|
14
|
+
export { NavListSkeleton } from './NavListSkeleton';
|