@astryxdesign/core 0.7.1 → 0.7.2-canary.c68f5e8
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/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +6 -0
- package/dist/astryx.css +1 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +19 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
- package/locales/en.json +76 -0
- package/locales/pseudo.json +57 -0
- package/package.json +4 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Switch/Switch.test.tsx +28 -0
- package/src/Switch/Switch.tsx +6 -0
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +21 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '@testing-library/react';
|
|
21
21
|
import userEvent from '@testing-library/user-event';
|
|
22
22
|
import {Pagination, generatePageRange} from './Pagination';
|
|
23
|
+
import {InternationalizationProvider} from '../i18n';
|
|
23
24
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
24
25
|
|
|
25
26
|
afterEach(() => {
|
|
@@ -119,6 +120,28 @@ describe('Pagination', () => {
|
|
|
119
120
|
).toBeInTheDocument();
|
|
120
121
|
});
|
|
121
122
|
|
|
123
|
+
it('does not introduce Stack theme targets around navigation controls', () => {
|
|
124
|
+
const {rerender} = render(
|
|
125
|
+
<Pagination page={3} onChange={() => {}} totalPages={5} />,
|
|
126
|
+
);
|
|
127
|
+
expect(
|
|
128
|
+
screen.getByRole('navigation').querySelector('.astryx-stack'),
|
|
129
|
+
).toBeNull();
|
|
130
|
+
|
|
131
|
+
rerender(
|
|
132
|
+
<Pagination
|
|
133
|
+
page={3}
|
|
134
|
+
onChange={() => {}}
|
|
135
|
+
totalPages={5}
|
|
136
|
+
variant="compact"
|
|
137
|
+
hasFirstLast
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByRole('navigation').querySelector('.astryx-stack'),
|
|
142
|
+
).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
122
145
|
it('gives the prev/next carets a hover tooltip matching their accessible name', () => {
|
|
123
146
|
// The carets are icon-only, so sighted users need a visible label on
|
|
124
147
|
// hover, not just the accessible name. The tooltip reuses the same
|
|
@@ -291,6 +314,225 @@ describe('Pagination', () => {
|
|
|
291
314
|
);
|
|
292
315
|
expect(screen.getByText('Page 3 of 10')).toBeInTheDocument();
|
|
293
316
|
});
|
|
317
|
+
|
|
318
|
+
describe('first/last buttons', () => {
|
|
319
|
+
function navButtonNames(): string[] {
|
|
320
|
+
return within(screen.getByRole('navigation'))
|
|
321
|
+
.getAllByRole('button')
|
|
322
|
+
.map(button => button.getAttribute('aria-label') ?? '');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it('omits first/last when hasFirstLast is unset', () => {
|
|
326
|
+
render(
|
|
327
|
+
<Pagination
|
|
328
|
+
page={5}
|
|
329
|
+
onChange={() => {}}
|
|
330
|
+
totalPages={10}
|
|
331
|
+
variant="compact"
|
|
332
|
+
/>,
|
|
333
|
+
);
|
|
334
|
+
expect(navButtonNames()).toEqual([
|
|
335
|
+
'Go to previous page',
|
|
336
|
+
'Go to next page',
|
|
337
|
+
]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('omits first/last when hasFirstLast is false', () => {
|
|
341
|
+
render(
|
|
342
|
+
<Pagination
|
|
343
|
+
page={5}
|
|
344
|
+
onChange={() => {}}
|
|
345
|
+
totalPages={10}
|
|
346
|
+
variant="compact"
|
|
347
|
+
hasFirstLast={false}
|
|
348
|
+
/>,
|
|
349
|
+
);
|
|
350
|
+
expect(navButtonNames()).toEqual([
|
|
351
|
+
'Go to previous page',
|
|
352
|
+
'Go to next page',
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('flanks prev/next with first/last when hasFirstLast is true', () => {
|
|
357
|
+
render(
|
|
358
|
+
<Pagination
|
|
359
|
+
page={5}
|
|
360
|
+
onChange={() => {}}
|
|
361
|
+
totalPages={10}
|
|
362
|
+
variant="compact"
|
|
363
|
+
hasFirstLast
|
|
364
|
+
/>,
|
|
365
|
+
);
|
|
366
|
+
expect(navButtonNames()).toEqual([
|
|
367
|
+
'Go to first page',
|
|
368
|
+
'Go to previous page',
|
|
369
|
+
'Go to next page',
|
|
370
|
+
'Go to last page',
|
|
371
|
+
]);
|
|
372
|
+
// The readout stays between prev and next.
|
|
373
|
+
const readout = within(screen.getByRole('navigation')).getByText(
|
|
374
|
+
'Page 5 of 10',
|
|
375
|
+
);
|
|
376
|
+
const prev = screen.getByRole('button', {name: 'Go to previous page'});
|
|
377
|
+
const next = screen.getByRole('button', {name: 'Go to next page'});
|
|
378
|
+
expect(
|
|
379
|
+
prev.compareDocumentPosition(readout) &
|
|
380
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
381
|
+
).toBeTruthy();
|
|
382
|
+
expect(
|
|
383
|
+
next.compareDocumentPosition(readout) &
|
|
384
|
+
Node.DOCUMENT_POSITION_PRECEDING,
|
|
385
|
+
).toBeTruthy();
|
|
386
|
+
// Compact never gains an editable box.
|
|
387
|
+
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('jumps to the first and last page through onChange and changeAction', async () => {
|
|
391
|
+
const user = userEvent.setup();
|
|
392
|
+
const onChange = vi.fn();
|
|
393
|
+
const changeAction = vi.fn();
|
|
394
|
+
render(
|
|
395
|
+
<Pagination
|
|
396
|
+
page={5}
|
|
397
|
+
onChange={onChange}
|
|
398
|
+
changeAction={changeAction}
|
|
399
|
+
totalItems={95}
|
|
400
|
+
pageSize={10}
|
|
401
|
+
variant="compact"
|
|
402
|
+
hasFirstLast
|
|
403
|
+
/>,
|
|
404
|
+
);
|
|
405
|
+
await user.click(
|
|
406
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
407
|
+
);
|
|
408
|
+
expect(onChange).toHaveBeenLastCalledWith(1);
|
|
409
|
+
expect(changeAction).toHaveBeenLastCalledWith(1);
|
|
410
|
+
await waitFor(() => {
|
|
411
|
+
expect(politeRegion()).toHaveTextContent('Page 1 of 10');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
await user.click(screen.getByRole('button', {name: 'Go to last page'}));
|
|
415
|
+
// 95 items at 10 per page is 10 pages.
|
|
416
|
+
expect(onChange).toHaveBeenLastCalledWith(10);
|
|
417
|
+
expect(changeAction).toHaveBeenLastCalledWith(10);
|
|
418
|
+
await waitFor(() => {
|
|
419
|
+
expect(politeRegion()).toHaveTextContent('Page 10 of 10');
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('disables first on the first page and last on the last page', () => {
|
|
424
|
+
const {rerender} = render(
|
|
425
|
+
<Pagination
|
|
426
|
+
page={1}
|
|
427
|
+
onChange={() => {}}
|
|
428
|
+
totalPages={10}
|
|
429
|
+
variant="compact"
|
|
430
|
+
hasFirstLast
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
expect(
|
|
434
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
435
|
+
).toBeDisabled();
|
|
436
|
+
expect(
|
|
437
|
+
screen.getByRole('button', {name: 'Go to last page'}),
|
|
438
|
+
).not.toBeDisabled();
|
|
439
|
+
|
|
440
|
+
rerender(
|
|
441
|
+
<Pagination
|
|
442
|
+
page={10}
|
|
443
|
+
onChange={() => {}}
|
|
444
|
+
totalPages={10}
|
|
445
|
+
variant="compact"
|
|
446
|
+
hasFirstLast
|
|
447
|
+
/>,
|
|
448
|
+
);
|
|
449
|
+
expect(
|
|
450
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
451
|
+
).not.toBeDisabled();
|
|
452
|
+
expect(
|
|
453
|
+
screen.getByRole('button', {name: 'Go to last page'}),
|
|
454
|
+
).toBeDisabled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('omits first/last when the total page count is unknown', async () => {
|
|
458
|
+
const user = userEvent.setup();
|
|
459
|
+
const onChange = vi.fn();
|
|
460
|
+
render(
|
|
461
|
+
<Pagination
|
|
462
|
+
page={2}
|
|
463
|
+
onChange={onChange}
|
|
464
|
+
hasMore
|
|
465
|
+
variant="compact"
|
|
466
|
+
hasFirstLast
|
|
467
|
+
/>,
|
|
468
|
+
);
|
|
469
|
+
expect(navButtonNames()).toEqual([
|
|
470
|
+
'Go to previous page',
|
|
471
|
+
'Go to next page',
|
|
472
|
+
]);
|
|
473
|
+
await user.click(screen.getByRole('button', {name: 'Go to next page'}));
|
|
474
|
+
expect(onChange).toHaveBeenLastCalledWith(3);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('mirrors the first/last chevrons under RTL like prev/next', () => {
|
|
478
|
+
render(
|
|
479
|
+
<InternationalizationProvider locale="en" dir="rtl">
|
|
480
|
+
<div dir="rtl">
|
|
481
|
+
<Pagination
|
|
482
|
+
page={5}
|
|
483
|
+
onChange={() => {}}
|
|
484
|
+
totalPages={10}
|
|
485
|
+
variant="compact"
|
|
486
|
+
hasFirstLast
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
</InternationalizationProvider>,
|
|
490
|
+
);
|
|
491
|
+
// jsdom applies no CSS transforms, so assert the first/last icons carry
|
|
492
|
+
// every class the mirrored prev/next icons share (the mirror included).
|
|
493
|
+
const iconClasses = (name: string) =>
|
|
494
|
+
new Set(
|
|
495
|
+
screen
|
|
496
|
+
.getByRole('button', {name})
|
|
497
|
+
.querySelector('.astryx-icon')
|
|
498
|
+
?.getAttribute('class')
|
|
499
|
+
?.split(/\s+/) ?? [],
|
|
500
|
+
);
|
|
501
|
+
const prev = iconClasses('Go to previous page');
|
|
502
|
+
const shared = [...iconClasses('Go to next page')].filter(name =>
|
|
503
|
+
prev.has(name),
|
|
504
|
+
);
|
|
505
|
+
expect(shared.length).toBeGreaterThan(0);
|
|
506
|
+
for (const name of ['Go to first page', 'Go to last page']) {
|
|
507
|
+
const classes = iconClasses(name);
|
|
508
|
+
expect(shared.filter(c => !classes.has(c))).toEqual([]);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('hasFirstLast on other variants', () => {
|
|
515
|
+
it.each(['pages', 'count', 'dots', 'none'] as const)(
|
|
516
|
+
'does not add first/last to the %s variant',
|
|
517
|
+
variant => {
|
|
518
|
+
render(
|
|
519
|
+
<Pagination
|
|
520
|
+
page={3}
|
|
521
|
+
onChange={() => {}}
|
|
522
|
+
totalItems={50}
|
|
523
|
+
pageSize={10}
|
|
524
|
+
variant={variant}
|
|
525
|
+
hasFirstLast
|
|
526
|
+
/>,
|
|
527
|
+
);
|
|
528
|
+
expect(
|
|
529
|
+
screen.queryByRole('button', {name: 'Go to first page'}),
|
|
530
|
+
).not.toBeInTheDocument();
|
|
531
|
+
expect(
|
|
532
|
+
screen.queryByRole('button', {name: 'Go to last page'}),
|
|
533
|
+
).not.toBeInTheDocument();
|
|
534
|
+
},
|
|
535
|
+
);
|
|
294
536
|
});
|
|
295
537
|
|
|
296
538
|
// ---------------------------------------------------------------------------
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Pagination.tsx
|
|
7
7
|
* @input Uses React, StyleX, Button, Icon, Selector, Text; page number buttons delegate to Button.
|
|
8
|
+
* Compact first/last buttons cancel the inside control gap with logical token margins.
|
|
8
9
|
* Prev/next and first/last chevrons mirror under RTL via the shared rtlStyles.mirror
|
|
9
|
-
* (CSS scaleX), not a JS direction read. The input
|
|
10
|
-
* (first/last) icons.
|
|
10
|
+
* (CSS scaleX), not a JS direction read. The input and compact variants use the
|
|
11
|
+
* chevronsLeft/chevronsRight (first/last) icons.
|
|
11
12
|
* @output Exports Pagination component, PaginationProps, PaginationVariant, PaginationSize types
|
|
12
13
|
* @position Core implementation; consumed by index.ts, tested by Pagination.test.tsx
|
|
13
14
|
*
|
|
@@ -105,7 +106,8 @@ export interface PaginationProps extends Omit<
|
|
|
105
106
|
* Visual variant controlling what appears between prev/next buttons.
|
|
106
107
|
* - pages: Page number buttons with ellipsis (default)
|
|
107
108
|
* - count: "X–Y of Z" text
|
|
108
|
-
* - compact: "Page X of Y" text
|
|
109
|
+
* - compact: "Page X of Y" text. First/last buttons are opt-in (see
|
|
110
|
+
* hasFirstLast).
|
|
109
111
|
* - dots: Dot indicators
|
|
110
112
|
* - input: An editable number box rendering "Page [ n ] / N". The leading
|
|
111
113
|
* noun is controlled by `pageLabel`. First/last double-chevron buttons
|
|
@@ -122,9 +124,11 @@ export interface PaginationProps extends Omit<
|
|
|
122
124
|
pageLabel?: string;
|
|
123
125
|
/**
|
|
124
126
|
* Whether to show first/last («/») double-chevron buttons flanking
|
|
125
|
-
* prev/next.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
127
|
+
* prev/next. Applies to the `input` variant (shown unless false) and the
|
|
128
|
+
* `compact` variant (shown only when true); other variants ignore it.
|
|
129
|
+
* The last button needs a known total — both are omitted when the page count
|
|
130
|
+
* is unknown (cursor/hasMore pagination).
|
|
131
|
+
* @default true for `input`, false for `compact`
|
|
128
132
|
*/
|
|
129
133
|
hasFirstLast?: boolean;
|
|
130
134
|
/**
|
|
@@ -181,6 +185,12 @@ const styles = stylex.create({
|
|
|
181
185
|
alignItems: 'center',
|
|
182
186
|
gap: spacingVars['--spacing-1'],
|
|
183
187
|
},
|
|
188
|
+
compactFirst: {
|
|
189
|
+
marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
190
|
+
},
|
|
191
|
+
compactLast: {
|
|
192
|
+
marginInlineStart: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
193
|
+
},
|
|
184
194
|
ellipsis: {
|
|
185
195
|
display: 'inline-flex',
|
|
186
196
|
alignItems: 'center',
|
|
@@ -369,7 +379,7 @@ export function Pagination({
|
|
|
369
379
|
onPageSizeChange,
|
|
370
380
|
variant = 'pages',
|
|
371
381
|
pageLabel,
|
|
372
|
-
hasFirstLast
|
|
382
|
+
hasFirstLast: hasFirstLastProp,
|
|
373
383
|
step: stepProp = 1,
|
|
374
384
|
siblingCount = 1,
|
|
375
385
|
size = 'md',
|
|
@@ -572,10 +582,14 @@ export function Pagination({
|
|
|
572
582
|
const buttonSize = size === 'sm' ? 'sm' : 'md';
|
|
573
583
|
const isSm = size === 'sm';
|
|
574
584
|
|
|
575
|
-
// First/last buttons
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
585
|
+
// First/last buttons are on by default in the input variant and opt-in for
|
|
586
|
+
// compact; other variants never render them. They also need a known page
|
|
587
|
+
// count (the last button would have no target otherwise).
|
|
588
|
+
const hasFirstLast =
|
|
589
|
+
variant === 'input'
|
|
590
|
+
? (hasFirstLastProp ?? true)
|
|
591
|
+
: variant === 'compact' && hasFirstLastProp === true;
|
|
592
|
+
const showFirstLast = hasFirstLast && computedTotalPages != null;
|
|
579
593
|
|
|
580
594
|
const renderIndicator = () => {
|
|
581
595
|
switch (variant) {
|
|
@@ -819,6 +833,7 @@ export function Pagination({
|
|
|
819
833
|
onClick={handleFirst}
|
|
820
834
|
isDisabled={isDisabled || !hasPrevious}
|
|
821
835
|
isIconOnly
|
|
836
|
+
xstyle={variant === 'compact' && styles.compactFirst}
|
|
822
837
|
/>
|
|
823
838
|
)}
|
|
824
839
|
|
|
@@ -874,6 +889,7 @@ export function Pagination({
|
|
|
874
889
|
onClick={handleLast}
|
|
875
890
|
isDisabled={isDisabled || !hasNext}
|
|
876
891
|
isIconOnly
|
|
892
|
+
xstyle={variant === 'compact' && styles.compactLast}
|
|
877
893
|
/>
|
|
878
894
|
)}
|
|
879
895
|
</div>
|
|
@@ -834,6 +834,34 @@ describe('Switch', () => {
|
|
|
834
834
|
expect(root).toHaveAttribute('id', 'switch-1');
|
|
835
835
|
expect(root).toHaveAttribute('aria-label', 'Toggle notifications');
|
|
836
836
|
});
|
|
837
|
+
|
|
838
|
+
it('composes a caller aria-describedby onto the switch control', () => {
|
|
839
|
+
const {container} = render(
|
|
840
|
+
<>
|
|
841
|
+
<p id="external-hint">Applies to every repository</p>
|
|
842
|
+
<Switch
|
|
843
|
+
label="Notifications"
|
|
844
|
+
description="Sends a daily digest"
|
|
845
|
+
value={false}
|
|
846
|
+
onChange={() => {}}
|
|
847
|
+
data-testid="my-switch"
|
|
848
|
+
aria-describedby="external-hint"
|
|
849
|
+
/>
|
|
850
|
+
</>,
|
|
851
|
+
);
|
|
852
|
+
const control = screen.getByRole('switch');
|
|
853
|
+
const description = screen.getByText('Sends a daily digest');
|
|
854
|
+
expect(control.getAttribute('aria-describedby')?.split(' ')).toEqual([
|
|
855
|
+
'external-hint',
|
|
856
|
+
description.id,
|
|
857
|
+
]);
|
|
858
|
+
expect(control).toHaveAccessibleDescription(
|
|
859
|
+
'Applies to every repository Sends a daily digest',
|
|
860
|
+
);
|
|
861
|
+
// The id reference lives on the control only, not on the field wrapper.
|
|
862
|
+
const root = container.querySelector('[data-testid="my-switch"]');
|
|
863
|
+
expect(root).not.toHaveAttribute('aria-describedby');
|
|
864
|
+
});
|
|
837
865
|
});
|
|
838
866
|
|
|
839
867
|
describe('pressed state', () => {
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -566,6 +566,7 @@ export function Switch({
|
|
|
566
566
|
className,
|
|
567
567
|
style,
|
|
568
568
|
ref,
|
|
569
|
+
'aria-describedby': ariaDescribedByProp,
|
|
569
570
|
...rest
|
|
570
571
|
}: SwitchProps) {
|
|
571
572
|
const t = useTranslator();
|
|
@@ -610,7 +611,12 @@ export function Switch({
|
|
|
610
611
|
// Only include descriptionID when the element actually renders.
|
|
611
612
|
// FieldLabel renders the description (with descriptionID) even when the
|
|
612
613
|
// label is visually hidden — it's sr-only, so keep it linked.
|
|
614
|
+
// A consumer's own `aria-describedby` describes the control, so it comes
|
|
615
|
+
// first here rather than landing on the field wrapper via `...rest`.
|
|
613
616
|
const describedByParts: string[] = [];
|
|
617
|
+
if (ariaDescribedByProp) {
|
|
618
|
+
describedByParts.push(ariaDescribedByProp);
|
|
619
|
+
}
|
|
614
620
|
if (description) {
|
|
615
621
|
describedByParts.push(descriptionID);
|
|
616
622
|
}
|
|
@@ -21,6 +21,14 @@ export const docs = {
|
|
|
21
21
|
usage: {
|
|
22
22
|
description:
|
|
23
23
|
'Wraps your app to set the active locale and (optionally) merge additional translation catalogs + per-locale overrides. Astryx components inside the subtree resolve their strings against this context. If no provider is present, components fall back to the shipped English defaults.',
|
|
24
|
+
anatomy: [
|
|
25
|
+
{
|
|
26
|
+
name: 'Provider boundary',
|
|
27
|
+
required: true,
|
|
28
|
+
description:
|
|
29
|
+
'A nonvisual context boundary that supplies locale, message catalogs, overrides, and direction to descendant Astryx components. It renders no DOM element.',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
24
32
|
bestPractices: [
|
|
25
33
|
{
|
|
26
34
|
guidance: true,
|