@astryxdesign/core 0.7.2-canary.caec7ca → 0.7.2-canary.cbb7fa3

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.
Files changed (125) hide show
  1. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +44 -3
  4. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  5. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  9. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  10. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  11. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +5 -2
  14. package/dist/Dialog/Dialog.d.ts.map +1 -1
  15. package/dist/Dialog/Dialog.js +20 -8
  16. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  17. package/dist/DropdownMenu/DropdownMenu.js +46 -3
  18. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  19. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
  23. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  24. package/dist/EmptyState/EmptyState.js +4 -10
  25. package/dist/Field/Field.d.ts +1 -1
  26. package/dist/Field/Field.d.ts.map +1 -1
  27. package/dist/Field/Field.js +8 -4
  28. package/dist/Grid/GridSpan.d.ts.map +1 -1
  29. package/dist/Grid/GridSpan.js +1 -0
  30. package/dist/List/List.d.ts +46 -19
  31. package/dist/List/List.d.ts.map +1 -1
  32. package/dist/List/List.js +28 -10
  33. package/dist/List/ListContext.d.ts +8 -3
  34. package/dist/List/ListContext.d.ts.map +1 -1
  35. package/dist/List/ListContext.js +7 -2
  36. package/dist/List/ListItem.d.ts +4 -3
  37. package/dist/List/ListItem.d.ts.map +1 -1
  38. package/dist/List/ListItem.js +19 -8
  39. package/dist/List/index.d.ts +1 -1
  40. package/dist/List/index.d.ts.map +1 -1
  41. package/dist/Markdown/Markdown.d.ts.map +1 -1
  42. package/dist/Markdown/Markdown.js +8 -1
  43. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  44. package/dist/MetadataList/MetadataList.js +5 -1
  45. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  46. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  47. package/dist/RadioList/RadioList.d.ts +1 -1
  48. package/dist/RadioList/RadioList.d.ts.map +1 -1
  49. package/dist/RadioList/RadioList.js +6 -2
  50. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  51. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  52. package/dist/ToggleButton/ToggleButton.js +12 -1
  53. package/dist/TreeList/TreeList.d.ts.map +1 -1
  54. package/dist/TreeList/TreeList.js +1 -0
  55. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  56. package/dist/TreeList/TreeListBranches.js +2 -2
  57. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  58. package/dist/TreeList/TreeListItem.js +57 -9
  59. package/dist/astryx.css +16 -3
  60. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  61. package/dist/hooks/useMenuHover.js +18 -13
  62. package/dist/utils/plainDate.d.ts.map +1 -1
  63. package/dist/utils/plainDate.js +15 -9
  64. package/package.json +3 -3
  65. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  66. package/src/AspectRatio/AspectRatio.tsx +38 -18
  67. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  68. package/src/Avatar/Avatar.doc.mjs +5 -3
  69. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  70. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  71. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  72. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  73. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  74. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  75. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  76. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  77. package/src/ContextMenu/ContextMenu.tsx +4 -1
  78. package/src/Dialog/Dialog.test.tsx +46 -0
  79. package/src/Dialog/Dialog.tsx +20 -7
  80. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  81. package/src/DropdownMenu/DropdownMenu.tsx +45 -2
  82. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  83. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  84. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
  85. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  86. package/src/EmptyState/EmptyState.spec.md +189 -0
  87. package/src/EmptyState/EmptyState.test.tsx +74 -3
  88. package/src/EmptyState/EmptyState.tsx +4 -8
  89. package/src/Field/Field.doc.mjs +14 -8
  90. package/src/Field/Field.spec.md +217 -0
  91. package/src/Field/Field.test.tsx +60 -2
  92. package/src/Field/Field.tsx +17 -8
  93. package/src/Grid/GridSpan.tsx +3 -0
  94. package/src/Item/Item.doc.mjs +1 -1
  95. package/src/Item/Item.tsx +1 -1
  96. package/src/List/List.doc.mjs +14 -1
  97. package/src/List/List.spec.md +129 -77
  98. package/src/List/List.test.tsx +144 -94
  99. package/src/List/List.tsx +74 -18
  100. package/src/List/ListContext.tsx +9 -3
  101. package/src/List/ListItem.tsx +46 -26
  102. package/src/List/index.ts +1 -1
  103. package/src/Markdown/Markdown.tsx +14 -1
  104. package/src/MetadataList/MetadataList.doc.mjs +2 -2
  105. package/src/MetadataList/MetadataList.test.tsx +38 -0
  106. package/src/MetadataList/MetadataList.tsx +8 -1
  107. package/src/MetadataList/MetadataListContext.tsx +1 -1
  108. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  109. package/src/RadioList/RadioList.tsx +4 -1
  110. package/src/Slider/Slider.spec.md +39 -18
  111. package/src/TabList/TabList.spec.md +57 -44
  112. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  113. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  114. package/src/ToggleButton/ToggleButton.tsx +12 -1
  115. package/src/TreeList/TreeList.doc.mjs +1 -0
  116. package/src/TreeList/TreeList.test.tsx +327 -6
  117. package/src/TreeList/TreeList.tsx +5 -1
  118. package/src/TreeList/TreeListBranches.tsx +6 -0
  119. package/src/TreeList/TreeListItem.tsx +88 -13
  120. package/src/docPropLiterals.test.ts +36 -10
  121. package/src/hooks/useMenuHover.ts +18 -13
  122. package/src/reset.css +0 -29
  123. package/src/theme/derivedVarRegistry.test.ts +7 -0
  124. package/src/utils/plainDate.test.ts +37 -1
  125. package/src/utils/plainDate.ts +15 -5
@@ -3,14 +3,14 @@
3
3
  /**
4
4
  * @file List.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, List, ListItem
6
- * @output Unit tests for List and ListItem, including bounded inline edge compensation
6
+ * @output Unit tests for List and ListItem, including the row edge treatment
7
7
  * @position Testing; validates List.tsx and ListItem.tsx implementation
8
8
  *
9
9
  * SYNC: When modified, update this header
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi} from 'vitest';
13
- import {render, screen, waitFor} from '@testing-library/react';
13
+ import {cleanup, render, screen, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {rulesDeclaredFor} from '../__tests__/pressState';
16
16
  import {List} from './List';
@@ -277,121 +277,171 @@ describe('List', () => {
277
277
  });
278
278
 
279
279
  // ===========================================================================
280
- // Inline edge compensation
280
+ // Edge treatment
281
281
  // ===========================================================================
282
282
 
283
- it('does not apply inline edge compensation styles by default', () => {
283
+ /** Every injected rule that names one of the element's classes. */
284
+ function declarationsFor(el: Element): string {
285
+ return rulesDeclaredFor(el).join('\n').replace(/\s+/g, '');
286
+ }
287
+
288
+ /** The declarations of the row inset variable that reach the row. */
289
+ function insetDeclarations(item: Element): string[] {
290
+ return rulesDeclaredFor(item).filter(rule =>
291
+ rule.includes('--_item-inset-inline:'),
292
+ );
293
+ }
294
+
295
+ const ZERO_INSET = /--_item-inset-inline:\s*0(px)?\s*;/;
296
+ const LIST_REACH = [
297
+ 'margin-inline-start:calc(-1*var(--container-padding-inline-start,0px))',
298
+ 'margin-inline-end:calc(-1*var(--container-padding-inline-end,0px))',
299
+ 'width:calc(100%+var(--container-padding-inline-start,0px)+var(--container-padding-inline-end,0px))',
300
+ ];
301
+ const OUTSET_ROW = [
302
+ 'margin-inline-start:max(var(--container-padding-inline-start,0px)-var(--_item-inset-inline),0px)',
303
+ 'margin-inline-end:max(var(--container-padding-inline-end,0px)-var(--_item-inset-inline),0px)',
304
+ ];
305
+ const BLEED_ROW = [
306
+ 'padding-inline-start:max(var(--container-padding-inline-start,0px),var(--_item-inset-inline))',
307
+ 'padding-inline-end:max(var(--container-padding-inline-end,0px),var(--_item-inset-inline))',
308
+ ];
309
+
310
+ function renderEdge(props: Partial<React.ComponentProps<typeof List>>) {
284
311
  const {container} = render(
285
- <List>
286
- <ListItem label="Item" />
312
+ <List header={<span>Items</span>} {...props}>
313
+ <ListItem label="Static" />
314
+ <ListItem label="Button" onClick={() => {}} />
315
+ <ListItem label="Link" href="/docs" />
287
316
  </List>,
288
317
  );
289
- const item = container.querySelector('li')!;
290
- expect(item.className).not.toContain('inlineEdgeCompensation');
291
- });
318
+ const ul = container.querySelector('ul')!;
319
+ return {
320
+ ul,
321
+ header: ul.parentElement!,
322
+ items: [...ul.querySelectorAll('li')],
323
+ };
324
+ }
292
325
 
293
- it('applies the clamped cancelling margin to each item when edgeCompensation is inline', () => {
294
- // The margin lives on the row element itself — the only place that can
295
- // read --_item-inset-inline, which Item sets on the same element (custom
296
- // properties cascade downward, so the <ul> cannot read it).
297
- const {container} = render(
298
- <List edgeCompensation="inline">
299
- <ListItem label="Item 1" />
300
- <ListItem label="Item 2" />
301
- </List>,
302
- );
303
- const items = container.querySelectorAll('li');
304
- expect(items).toHaveLength(2);
326
+ it('keeps the row padding and leaves the list and rows in place by default', () => {
327
+ const {ul, items} = renderEdge({});
305
328
  for (const item of items) {
306
- expect(item.className).toContain('inlineEdgeCompensation');
329
+ expect(item.className).not.toMatch(/edge(Flush|Outset|Bleed)/);
330
+ expect(insetDeclarations(item)).toHaveLength(1);
331
+ expect(insetDeclarations(item)[0]).not.toMatch(ZERO_INSET);
307
332
  }
308
- expect(container.querySelector('ul')!.className).not.toContain(
309
- 'inlineEdgeCompensation',
310
- );
333
+ expect(ul.className).not.toContain('reachesContainerPadding');
334
+ expect(edgeContext(ul)).toBe('padded');
311
335
  });
312
336
 
313
- it('uses the same var-derived cancel for every density', () => {
314
- // The cancelling margin reads --_item-inset-inline instead of hardcoding
315
- // per-density values, so all densities (and theme paddingInline
316
- // overrides on `item`) share one style.
337
+ /** The edge each row resolved, read from the row's own edge class. */
338
+ function edgeContext(ul: Element): string {
339
+ const edges = [...ul.querySelectorAll('li')].map(li => {
340
+ const match = /edge(Flush|Outset|Bleed)/.exec(li.className);
341
+ return match ? match[1].toLowerCase() : 'padded';
342
+ });
343
+ expect(new Set(edges).size).toBe(1);
344
+ return edges[0];
345
+ }
346
+
347
+ it('applies one treatment to every row, static or interactive, and never to the header', () => {
348
+ for (const edge of ['flush', 'outset', 'bleed'] as const) {
349
+ const {ul, header, items} = renderEdge({edge});
350
+ expect(items).toHaveLength(3);
351
+ expect(edgeContext(ul)).toBe(edge);
352
+ expect(header.className).not.toMatch(/edge|reachesContainerPadding/);
353
+ cleanup();
354
+ }
355
+ });
356
+
357
+ it('flush: zeroes the shared inset at every density and leaves the list in place', () => {
317
358
  for (const density of ['compact', 'balanced', 'spacious'] as const) {
318
- const {container, unmount} = render(
319
- <List edgeCompensation="inline" density={density}>
320
- <ListItem label="Item" />
321
- </List>,
322
- );
323
- expect(container.querySelector('li')!.className).toContain(
324
- 'inlineEdgeCompensation',
325
- );
326
- unmount();
359
+ const {ul, items} = renderEdge({edge: 'flush', density});
360
+ expect(ul.className).not.toContain('reachesContainerPadding');
361
+ for (const item of items) {
362
+ expect(item).toHaveAttribute('data-density', density);
363
+ const insets = insetDeclarations(item);
364
+ expect(insets).toHaveLength(1);
365
+ expect(insets[0]).toMatch(ZERO_INSET);
366
+ }
367
+ cleanup();
327
368
  }
328
369
  });
329
370
 
330
- it('does not pull the header when edgeCompensation is inline', () => {
331
- // The negative margin lives on the row elements, so the header keeps its
332
- // position and the row text aligns up to it.
333
- const {container} = render(
334
- <List edgeCompensation="inline" header={<span>Items</span>}>
335
- <ListItem label="Item" />
336
- </List>,
337
- );
338
- expect(container.querySelector('li')!.className).toContain(
339
- 'inlineEdgeCompensation',
340
- );
341
- const ul = container.querySelector('ul')!;
342
- expect(ul.className).not.toContain('inlineEdgeCompensation');
343
- expect(ul.parentElement?.className).not.toContain('inlineEdgeCompensation');
371
+ it('outset: the list reaches the container padding and each row steps back in by what its inset does not fill, per edge', () => {
372
+ // Each edge reads its own container padding var: one var on both edges
373
+ // over-reaches the other edge under asymmetric padding (16px start /
374
+ // 4px end), so a selected row would paint past the outer border.
375
+ const {ul, items} = renderEdge({edge: 'outset'});
376
+ const listCss = declarationsFor(ul);
377
+ for (const declaration of LIST_REACH) {
378
+ expect(listCss).toContain(declaration);
379
+ }
380
+ for (const item of items) {
381
+ const css = declarationsFor(item);
382
+ for (const declaration of OUTSET_ROW) {
383
+ expect(css).toContain(declaration);
384
+ }
385
+ expect(css).not.toContain(
386
+ 'margin-inline-end:max(var(--container-padding-inline-start',
387
+ );
388
+ expect(insetDeclarations(item)[0]).not.toMatch(ZERO_INSET);
389
+ }
344
390
  });
345
391
 
346
- it('clamps each inline edge against its own container padding var', () => {
347
- // Regression for asymmetric container padding (16px start / 4px end):
348
- // clamping BOTH margins against --container-padding-inline-start
349
- // over-cancels the end edge by the difference, and the selected row
350
- // paints past the outer border (RTL mirrors the spill). Each edge must
351
- // clamp against its matching var; logical properties keep RTL correct.
352
- // jsdom does no real layout, so assert on the injected StyleX CSS
353
- // (runtimeInjection is on in the test config).
354
- const {container} = render(
355
- <List edgeCompensation="inline">
356
- <ListItem label="Item" />
357
- </List>,
358
- );
359
- expect(container.querySelector('li')!.className).toContain(
360
- 'inlineEdgeCompensation',
361
- );
362
- let out = '';
363
- for (const sheet of Array.from(document.styleSheets)) {
364
- try {
365
- for (const rule of Array.from(sheet.cssRules)) {
366
- out += rule.cssText + '\n';
367
- }
368
- } catch {
369
- // ignore cross-origin sheets
392
+ it('bleed: the list reaches the container padding and rows fill it with text on the padding line, per edge', () => {
393
+ const {ul, items} = renderEdge({edge: 'bleed'});
394
+ const listCss = declarationsFor(ul);
395
+ for (const declaration of LIST_REACH) {
396
+ expect(listCss).toContain(declaration);
397
+ }
398
+ for (const item of items) {
399
+ const css = declarationsFor(item);
400
+ for (const declaration of BLEED_ROW) {
401
+ expect(css).toContain(declaration);
370
402
  }
403
+ expect(css).toContain('border-radius:0');
404
+ expect(css).not.toMatch(/margin-inline-(start|end):max/);
371
405
  }
372
- out += Array.from(document.querySelectorAll('style'))
373
- .map(s => s.textContent || '')
374
- .join('\n');
375
- const css = out.replace(/\s+/g, '');
376
- expect(css).toContain(
377
- 'margin-inline-start:calc(-1*min(var(--_item-inset-inline),var(--container-padding-inline-start,0px)))',
378
- );
379
- expect(css).toContain(
380
- 'margin-inline-end:calc(-1*min(var(--_item-inset-inline),var(--container-padding-inline-end,0px)))',
381
- );
382
- // The end edge must not clamp against the start var (the over-cancel).
383
- expect(css).not.toContain(
384
- 'margin-inline-end:calc(-1*min(var(--_item-inset-inline),var(--container-padding-inline-start,0px)))',
385
- );
386
406
  });
387
407
 
388
- it('does not apply the cancelling margin to a ListItem outside a List', () => {
408
+ it('keeps the inline clip on the list for every treatment', () => {
409
+ for (const edge of ['padded', 'flush', 'outset', 'bleed'] as const) {
410
+ const {ul} = renderEdge({edge});
411
+ expect(declarationsFor(ul)).toContain('overflow-x:clip');
412
+ cleanup();
413
+ }
414
+ });
415
+
416
+ it('treats the deprecated edgeCompensation="inline" as edge="outset" without a runtime warning', () => {
417
+ // Plain use is reported statically by the @deprecated type; rendering
418
+ // never warns merely because the old name is used (spec:AST-017 FR29a).
419
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
420
+ const {ul} = renderEdge({edgeCompensation: 'inline'});
421
+ expect(edgeContext(ul)).toBe('outset');
422
+ expect(ul.className).toContain('reachesContainerPadding');
423
+ expect(warn).not.toHaveBeenCalled();
424
+ warn.mockRestore();
425
+ });
426
+
427
+ it('lets edge win over the deprecated edgeCompensation and warns about the conflict', () => {
428
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
429
+ const {ul} = renderEdge({edge: 'flush', edgeCompensation: 'inline'});
430
+ expect(edgeContext(ul)).toBe('flush');
431
+ expect(ul.className).not.toContain('reachesContainerPadding');
432
+ expect(warn).toHaveBeenCalledTimes(1);
433
+ const message = String(warn.mock.calls[0][0]);
434
+ expect(message).toContain('`edge` wins');
435
+ expect(message).toContain('astryx upgrade');
436
+ warn.mockRestore();
437
+ });
438
+
439
+ it('keeps the inset of a ListItem outside a List', () => {
389
440
  const {container} = render(<ListItem label="Standalone" />);
390
- expect(container.querySelector('li')!.className).not.toContain(
391
- 'inlineEdgeCompensation',
441
+ expect(container.querySelector('li')!.className).not.toMatch(
442
+ /edge(Flush|Outset|Bleed)/,
392
443
  );
393
444
  });
394
-
395
445
  // ===========================================================================
396
446
  // Dividers
397
447
  // ===========================================================================
package/src/List/List.tsx CHANGED
@@ -4,9 +4,10 @@
4
4
 
5
5
  /**
6
6
  * @file List.tsx
7
- * @input Uses React, ReactNode, StyleXStyles, theme tokens, ListContext
8
- * with optional inline edge compensation
9
- * @output Exports List component, ListProps, ListDensity, ListStyle types
7
+ * @input Uses React, ReactNode, StyleXStyles, theme tokens, useDevWarning,
8
+ * and ListContext with the row edge treatment
9
+ * @output Exports List component, ListProps, ListDensity, ListEdge, ListStyle
10
+ * types
10
11
  * @position Core implementation; consumed by index.ts, tested by List.test.tsx
11
12
  *
12
13
  * SYNC: When modified, update these files to stay in sync:
@@ -24,13 +25,16 @@ import type {BaseProps} from '../BaseProps';
24
25
  import {
25
26
  ListContext,
26
27
  type ListDensity,
28
+ type ListEdge,
27
29
  type ListMarkerStyle,
28
30
  } from './ListContext';
29
31
  import {mergeProps} from '../utils';
30
32
  import {themeProps} from '../utils/themeProps';
33
+ import {useDevWarning} from '../hooks/useDevWarning';
31
34
 
32
35
  export {
33
36
  type ListDensity,
37
+ type ListEdge,
34
38
  type ListMarkerStyle as ListStyle,
35
39
  } from './ListContext';
36
40
 
@@ -60,16 +64,39 @@ export interface ListProps extends BaseProps<
60
64
  hasDividers?: boolean;
61
65
 
62
66
  /**
63
- * Compensates for each item's built-in inline inset, up to the container
64
- * padding available on each edge. Use "inline" to bring row content toward
65
- * sibling content such as a section heading. Content aligns when the
66
- * container padding is at least the item inset; smaller padding leaves
67
- * some inset uncompensated.
68
- * The cancelling margin reads the same variable the items derive their
69
- * inline padding from, so it tracks density and theme padding overrides
70
- * automatically. Hover and selection backgrounds still extend past the
71
- * text by the inset.
72
- * Omit to leave item positions unchanged.
67
+ * Where each row's text, dividers, and hover and selection backgrounds sit,
68
+ * relative to the list's edges and the container around it.
69
+ * - 'padded': text sits inside each row's padding, so it starts a little in
70
+ * from a heading above the list and backgrounds have room around it
71
+ * - 'flush': rows drop their side padding, so text starts on the heading's
72
+ * line and dividers and backgrounds stop at the row's content, with no
73
+ * room beyond it on either side. Works anywhere. Suits static rows
74
+ * - 'outset': text sits on the heading's line, and dividers and backgrounds
75
+ * reach a little past it into the container's padding. Suits interactive
76
+ * rows in a container
77
+ * - 'bleed': text sits on the heading's line, and dividers and backgrounds
78
+ * reach the container's edges with square corners, like Table rows in a
79
+ * Card
80
+ *
81
+ * A row's first content is what lines up with the heading: its marker,
82
+ * icon, or avatar when it has one, otherwise its label. 'outset' and
83
+ * 'bleed' reach into the padding of the nearest container that shares its
84
+ * padding: a Card, Section, Dialog, or BottomSheet, or a transparent
85
+ * container such as a Layout region with explicit padding or a padded
86
+ * ScrollableArea. Place the List directly in that container's padding:
87
+ * padding that Stack, Center, or your own styles add in between is not
88
+ * counted. Drawers and the automatic padding of LayoutContent and
89
+ * LayoutPanel aren't supported yet. With no such container, including in a Popover, they look
90
+ * like 'padded'. In a container with less padding than the rows' own, text
91
+ * starts a little after the heading's line. A theme's inline padding on `item` replaces the row
92
+ * padding of every value, including the zero padding of 'flush'.
93
+ * @default 'padded'
94
+ */
95
+ edge?: ListEdge;
96
+
97
+ /**
98
+ * @deprecated Use `edge="outset"`, which this sets. Still works; `edge`
99
+ * wins when both are set. `astryx upgrade` rewrites existing usage.
73
100
  */
74
101
  edgeCompensation?: 'inline';
75
102
 
@@ -121,6 +148,18 @@ const styles = stylex.create({
121
148
  // vertical scroller. (StyleX emits the physical longhand of this axis.)
122
149
  overflowInline: 'clip',
123
150
  },
151
+ // 'outset' and 'bleed' rows reach into the container's padding. The list
152
+ // reaches the container's padding edge on each inline edge first, so its
153
+ // inline clip (above) moves out with the rows instead of cutting them; each
154
+ // row then places itself inside it. Container padding vars are inherited,
155
+ // so the list can read them, while the rows' own inset is scoped to the
156
+ // rows. Missing or reset container padding resolves to zero.
157
+ reachesContainerPadding: {
158
+ marginInlineStart: 'calc(-1 * var(--container-padding-inline-start, 0px))',
159
+ marginInlineEnd: 'calc(-1 * var(--container-padding-inline-end, 0px))',
160
+ width:
161
+ 'calc(100% + var(--container-padding-inline-start, 0px) + var(--container-padding-inline-end, 0px))',
162
+ },
124
163
  withDividers: {
125
164
  gap: 0,
126
165
  },
@@ -148,9 +187,10 @@ const dynamicStyles = stylex.create({
148
187
  * Renders semantic `<ul>` or `<ol>` elements with configurable density,
149
188
  * dividers, marker styles, and an optional header.
150
189
  *
151
- * Set `edgeCompensation="inline"` to compensate for the items' inline inset
152
- * up to the container padding available on each edge, for alignment with
153
- * sibling content such as a section heading.
190
+ * Set `edge` to choose where row text, dividers, and backgrounds sit: inside
191
+ * each row's padding (`padded`, the default), at the list's edges (`flush`),
192
+ * on a heading's line with backgrounds reaching into the container's padding
193
+ * (`outset`), or reaching the container's edges like Table rows (`bleed`).
154
194
  *
155
195
  * @example
156
196
  * ```
@@ -162,12 +202,16 @@ const dynamicStyles = stylex.create({
162
202
  * <ListItem label="First step" />
163
203
  * <ListItem label="Second step" />
164
204
  * </List>
205
+ * <List edge="flush" hasDividers>
206
+ * <ListItem label="Solstice Mug" description="Ceramic, 12 oz" />
207
+ * </List>
165
208
  * ```
166
209
  */
167
210
  export function List({
168
211
  children,
169
212
  density = 'balanced',
170
213
  hasDividers = false,
214
+ edge: edgeProp,
171
215
  edgeCompensation,
172
216
  header,
173
217
  listStyle = 'none',
@@ -182,10 +226,21 @@ export function List({
182
226
  const headerId = useId();
183
227
  const isOrdered = listStyle === 'decimal';
184
228
  const Tag = isOrdered ? 'ol' : 'ul';
229
+ // Plain use of the deprecated prop is reported by its @deprecated type;
230
+ // development warns only when the two names conflict (spec:AST-017 FR29).
231
+ useDevWarning(
232
+ 'List',
233
+ '`edge` and the deprecated `edgeCompensation` are both set; `edge` wins. ' +
234
+ 'Remove `edgeCompensation`. `astryx upgrade` rewrites existing usage.',
235
+ edgeProp != null && edgeCompensation != null,
236
+ );
237
+ const edge: ListEdge =
238
+ edgeProp ?? (edgeCompensation === 'inline' ? 'outset' : 'padded');
239
+ const reachesContainerPadding = edge === 'outset' || edge === 'bleed';
185
240
 
186
241
  const contextValue = useMemo(
187
- () => ({density, hasDividers, listStyle, edgeCompensation}),
188
- [density, hasDividers, listStyle, edgeCompensation],
242
+ () => ({density, hasDividers, edge, listStyle}),
243
+ [density, hasDividers, edge, listStyle],
189
244
  );
190
245
 
191
246
  const listElement = (
@@ -211,6 +266,7 @@ export function List({
211
266
  stylex.props(
212
267
  styles.list,
213
268
  hasDividers && styles.withDividers,
269
+ reachesContainerPadding && styles.reachesContainerPadding,
214
270
  listStyle !== 'none' &&
215
271
  (start != null && start !== 1
216
272
  ? dynamicStyles.counterStart(start - 1)
@@ -5,8 +5,8 @@
5
5
  /**
6
6
  * @file ListContext.tsx
7
7
  * @input Layer-scoped React context
8
- * @output Exports ListContext for sharing density, dividers, marker style,
9
- * and optional inline edge compensation between List and ListItem, and
8
+ * @output Exports ListContext for sharing density, dividers, the row edge
9
+ * treatment, and marker style between List and ListItem, and
10
10
  * ListMarkerScope, which sets the marker for the items inside it, or a
11
11
  * task item's checkbox in its place
12
12
  * @position Internal context; consumed by List.tsx and ListItem.tsx
@@ -18,6 +18,12 @@ import {createLayerScopedContext as createContext} from '../Layer/layerScopedCon
18
18
  export type ListDensity = 'compact' | 'balanced' | 'spacious';
19
19
  export type ListMarkerStyle = 'none' | 'disc' | 'decimal' | 'circle';
20
20
 
21
+ /**
22
+ * Where each row's content and its lines and backgrounds sit relative to the
23
+ * list's edges and the padding of the container around the list.
24
+ */
25
+ export type ListEdge = 'padded' | 'flush' | 'outset' | 'bleed';
26
+
21
27
  /**
22
28
  * Every marker ListItem draws: the public styles, and the square, letter,
23
29
  * and roman markers Markdown's nested lists use (spec:AST-061 DEC-6).
@@ -35,8 +41,8 @@ export interface ListTaskMarker {
35
41
  export interface ListContextValue {
36
42
  density: ListDensity;
37
43
  hasDividers: boolean;
44
+ edge: ListEdge;
38
45
  listStyle: ListMarkerStyle;
39
- edgeCompensation?: 'inline';
40
46
  /**
41
47
  * The marker items draw instead of the one `listStyle` names, set by
42
48
  * ListMarkerScope; internal. Ignored when `listStyle` is `'none'`.
@@ -4,9 +4,10 @@
4
4
 
5
5
  /**
6
6
  * @file ListItem.tsx
7
- * @input Uses React, ReactNode, StyleXStyles, theme tokens, List edge compensation,
8
- * CheckboxInput, and the marker or task checkbox a ListMarkerScope sets
9
- * (Markdown's nested lists and the task items of mixed lists)
7
+ * @input Uses React, ReactNode, StyleXStyles, theme tokens, the List row edge
8
+ * treatment, CheckboxInput, and the marker or task checkbox a
9
+ * ListMarkerScope sets (Markdown's nested lists and the task items of mixed
10
+ * lists)
10
11
  * @output Exports ListItem component, ListItemProps type
11
12
  * @position Core implementation; consumed by List, index.ts, tested by List.test.tsx
12
13
  *
@@ -23,6 +24,7 @@
23
24
 
24
25
  import {use, type ReactNode} from 'react';
25
26
  import * as stylex from '@stylexjs/stylex';
27
+ import type {StyleXStyles} from '@stylexjs/stylex';
26
28
  import {
27
29
  colorVars,
28
30
  spacingVars,
@@ -149,23 +151,37 @@ const styles = stylex.create({
149
151
  withCounter: {
150
152
  counterIncrement: 'astryx-list',
151
153
  },
152
- // List's inline edge compensation cancels as much of the row's built-in
153
- // inline inset as the container padding allows (e.g. under a heading).
154
- // The margins read --_item-inset-inline — the same variable Item derives
155
- // its paddingInline from — on the row element itself (custom properties
156
- // only cascade downward, so the <ul> could not read it). Density changes
157
- // and theme paddingInline overrides on `item` move both values together.
158
- // Each edge clamps against ITS OWN container padding var: a single
159
- // start-var clamp on both margins over-cancels the end edge under
160
- // asymmetric container padding (16px start / 4px end) and the selected
161
- // row paints past the outer border. Logical properties keep RTL correct,
162
- // and a zero-padding/full-bleed surface (min(inset, 0px) = 0px) leaves
163
- // the row in place instead of pulling it outside its content edge.
164
- inlineEdgeCompensation: {
154
+ // List's edge treatments. Each reads --_item-inset-inline, the variable
155
+ // Item derives its paddingInline from, on the row itself (custom properties
156
+ // only cascade downward, so the <ul> cannot read it); density and theme
157
+ // paddingInline overrides on `item` move it. Each edge resolves against ITS
158
+ // OWN container padding var, so asymmetric padding never over-reaches an
159
+ // edge, and logical properties keep RTL correct.
160
+ //
161
+ // 'flush': no inset, so content meets the row's edges.
162
+ edgeFlush: {
163
+ '--_item-inset-inline': '0px',
164
+ },
165
+ // 'outset': the list reaches the container's padding edge (List.tsx); the
166
+ // row steps back in by whatever padding its inset does not fill, so the row
167
+ // reaches min(inset, container padding) past the content line and its text
168
+ // lands on that line when the padding is at least the inset.
169
+ edgeOutset: {
165
170
  marginInlineStart:
166
- 'calc(-1 * min(var(--_item-inset-inline), var(--container-padding-inline-start, 0px)))',
171
+ 'max(var(--container-padding-inline-start, 0px) - var(--_item-inset-inline), 0px)',
167
172
  marginInlineEnd:
168
- 'calc(-1 * min(var(--_item-inset-inline), var(--container-padding-inline-end, 0px)))',
173
+ 'max(var(--container-padding-inline-end, 0px) - var(--_item-inset-inline), 0px)',
174
+ },
175
+ // 'bleed': the row fills the list, which reaches the container's padding
176
+ // edge (List.tsx); its text sits on the container's padding line, never
177
+ // closer to the row's edge than its own inset. Square corners: the row
178
+ // meets the container's straight sides.
179
+ edgeBleed: {
180
+ paddingInlineStart:
181
+ 'max(var(--container-padding-inline-start, 0px), var(--_item-inset-inline))',
182
+ paddingInlineEnd:
183
+ 'max(var(--container-padding-inline-end, 0px), var(--_item-inset-inline))',
184
+ borderRadius: 0,
169
185
  },
170
186
  withDivider: {
171
187
  borderBlockEndWidth: borderVars['--border-width'],
@@ -313,8 +329,8 @@ export function ListItem({
313
329
  const ctx = use(ListContext);
314
330
  const density = ctx?.density ?? 'balanced';
315
331
  const hasDividers = ctx?.hasDividers ?? false;
332
+ const edge = ctx?.edge ?? 'padded';
316
333
  const listStyle = ctx?.listStyle ?? 'none';
317
- const edgeCompensation = ctx?.edgeCompensation;
318
334
  const hasMarkers = listStyle !== 'none';
319
335
  // ListMarkerScope may name another marker for this item.
320
336
  const markerKind: ListMarker | null =
@@ -370,13 +386,17 @@ export function ListItem({
370
386
  isSelected={isSelected}
371
387
  align={align}
372
388
  density={density}
373
- xstyle={[
374
- hasMarkers && styles.withCounter,
375
- hasDividers && styles.withDivider,
376
- hasDividers && embeddedStyles.noRadius,
377
- edgeCompensation === 'inline' && styles.inlineEdgeCompensation,
378
- xstyle,
379
- ]}
389
+ xstyle={
390
+ [
391
+ hasMarkers && styles.withCounter,
392
+ hasDividers && styles.withDivider,
393
+ hasDividers && embeddedStyles.noRadius,
394
+ edge === 'flush' && styles.edgeFlush,
395
+ edge === 'outset' && styles.edgeOutset,
396
+ edge === 'bleed' && styles.edgeBleed,
397
+ xstyle,
398
+ ] as StyleXStyles
399
+ }
380
400
  {...mergeProps(themeProps('list-item'), {className, style})}
381
401
  {...restProps}
382
402
  />
package/src/List/index.ts CHANGED
@@ -12,6 +12,6 @@
12
12
  */
13
13
 
14
14
  export {List} from './List';
15
- export type {ListProps, ListStyle, ListDensity} from './List';
15
+ export type {ListProps, ListStyle, ListDensity, ListEdge} from './List';
16
16
  export {ListItem} from './ListItem';
17
17
  export type {ListItemProps} from './ListItem';
@@ -405,6 +405,16 @@ const markdownTableCellStyles = stylex.create({
405
405
  code: {
406
406
  wordBreak: 'normal',
407
407
  },
408
+ // The grid's outer edge. `dividers="grid"` draws the lines *between* cells
409
+ // and stops at the table's rim, so without this the top, bottom, and sides
410
+ // stay open and the grid reads as unfinished. On the table element, not the
411
+ // wrapper, so `border-collapse: collapse` merges it with the cell lines
412
+ // into one 1px rule rather than doubling up at the edge.
413
+ tableOuterEdge: {
414
+ borderWidth: borderVars['--border-width'],
415
+ borderStyle: 'solid',
416
+ borderColor: colorVars['--color-border'],
417
+ },
408
418
  });
409
419
 
410
420
  /**
@@ -1905,7 +1915,10 @@ function renderBlock(
1905
1915
  ),
1906
1916
  )}
1907
1917
  {...lineStamps}>
1908
- <Table dividers="rows" textOverflow="wrap">
1918
+ <Table
1919
+ dividers="grid"
1920
+ textOverflow="wrap"
1921
+ xstyle={markdownTableCellStyles.tableOuterEdge}>
1909
1922
  <TableHeader>
1910
1923
  <TableRow>
1911
1924
  {header?.children.map((h, i) => (
@@ -43,8 +43,8 @@ export const docs = {
43
43
  {
44
44
  name: 'label',
45
45
  type: "{ position?: 'start' | 'top', width?: number | string }",
46
- description: "Label display configuration. position controls label placement, width sets a custom label column width. Defaults to { position: 'top' } for multi-column layouts.",
47
- default: "{ position: 'start' } (single-column) / { position: 'top' } (multi-column)",
46
+ description: "Label display configuration. position controls label placement, width sets a custom label column width. Defaults to { position: 'top' } for multi-column layouts. Position is ignored in horizontal mode, which always uses { position: 'top' }.",
47
+ default: "{ position: 'start' } (vertical single-column) / { position: 'top' } (multi-column or horizontal)",
48
48
  },
49
49
  {
50
50
  name: 'maxNumOfItems',