@tempoxyz/ds 0.3.0 → 0.3.1

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 (173) hide show
  1. package/dist/web/components/Accordion.js.zyzz.json +1 -1
  2. package/dist/web/components/AgentLogo.js.zyzz.json +1 -1
  3. package/dist/web/components/Alert.css +1 -1
  4. package/dist/web/components/Alert.d.ts +7 -2
  5. package/dist/web/components/Alert.d.ts.map +1 -1
  6. package/dist/web/components/Alert.js +12 -7
  7. package/dist/web/components/Alert.js.map +1 -1
  8. package/dist/web/components/Alert.js.zyzz.json +1 -1
  9. package/dist/web/components/AlertDialog.js.zyzz.json +1 -1
  10. package/dist/web/components/Amount.js.zyzz.json +1 -1
  11. package/dist/web/components/AmountInput.js.zyzz.json +1 -1
  12. package/dist/web/components/AppShell.css +1 -1
  13. package/dist/web/components/AppShell.d.ts +108 -0
  14. package/dist/web/components/AppShell.d.ts.map +1 -1
  15. package/dist/web/components/AppShell.js +72 -24
  16. package/dist/web/components/AppShell.js.map +1 -1
  17. package/dist/web/components/AppShell.js.zyzz.json +1 -1
  18. package/dist/web/components/AssetChip.js.zyzz.json +1 -1
  19. package/dist/web/components/Autocomplete.js.zyzz.json +1 -1
  20. package/dist/web/components/Avatar.js.zyzz.json +1 -1
  21. package/dist/web/components/Badge.css +1 -1
  22. package/dist/web/components/Badge.d.ts +1 -1
  23. package/dist/web/components/Badge.d.ts.map +1 -1
  24. package/dist/web/components/Badge.js.map +1 -1
  25. package/dist/web/components/Badge.js.zyzz.json +1 -1
  26. package/dist/web/components/Banner.css +1 -1
  27. package/dist/web/components/Banner.js.zyzz.json +1 -1
  28. package/dist/web/components/BarChart.css +1 -1
  29. package/dist/web/components/BarChart.js.zyzz.json +1 -1
  30. package/dist/web/components/Breadcrumbs.js.zyzz.json +1 -1
  31. package/dist/web/components/Button.css +1 -1
  32. package/dist/web/components/Button.d.ts +10 -6
  33. package/dist/web/components/Button.d.ts.map +1 -1
  34. package/dist/web/components/Button.js +4 -3
  35. package/dist/web/components/Button.js.map +1 -1
  36. package/dist/web/components/Button.js.zyzz.json +1 -1
  37. package/dist/web/components/ButtonGroup.js.zyzz.json +1 -1
  38. package/dist/web/components/Calendar.css +1 -1
  39. package/dist/web/components/Calendar.js.zyzz.json +1 -1
  40. package/dist/web/components/Checkbox.js.zyzz.json +1 -1
  41. package/dist/web/components/CheckboxGroup.js.zyzz.json +1 -1
  42. package/dist/web/components/ChoiceGroup.css +1 -1
  43. package/dist/web/components/ChoiceGroup.js.zyzz.json +1 -1
  44. package/dist/web/components/Choicebox.js.zyzz.json +1 -1
  45. package/dist/web/components/Combobox.js.zyzz.json +1 -1
  46. package/dist/web/components/Container.js.zyzz.json +1 -1
  47. package/dist/web/components/CopyButton.js.zyzz.json +1 -1
  48. package/dist/web/components/DatePicker.css +1 -1
  49. package/dist/web/components/DatePicker.js.zyzz.json +1 -1
  50. package/dist/web/components/Dialog.d.ts.map +1 -1
  51. package/dist/web/components/Dialog.js +1 -1
  52. package/dist/web/components/Dialog.js.map +1 -1
  53. package/dist/web/components/Dialog.js.zyzz.json +1 -1
  54. package/dist/web/components/Drawer.js.zyzz.json +1 -1
  55. package/dist/web/components/Empty.css +1 -1
  56. package/dist/web/components/Empty.js.zyzz.json +1 -1
  57. package/dist/web/components/Field.js.zyzz.json +1 -1
  58. package/dist/web/components/Fieldset.js.zyzz.json +1 -1
  59. package/dist/web/components/Frame.css +1 -1
  60. package/dist/web/components/Frame.js.zyzz.json +1 -1
  61. package/dist/web/components/Gauge.js.zyzz.json +1 -1
  62. package/dist/web/components/Grid.js.zyzz.json +1 -1
  63. package/dist/web/components/Hex.css +1 -1
  64. package/dist/web/components/Hex.js.zyzz.json +1 -1
  65. package/dist/web/components/IconTile.css +1 -1
  66. package/dist/web/components/IconTile.js.zyzz.json +1 -1
  67. package/dist/web/components/Identicon.js.zyzz.json +1 -1
  68. package/dist/web/components/Inline.js.zyzz.json +1 -1
  69. package/dist/web/components/Inset.js.zyzz.json +1 -1
  70. package/dist/web/components/Kbd.css +1 -1
  71. package/dist/web/components/Kbd.js.zyzz.json +1 -1
  72. package/dist/web/components/ListGroup.css +1 -1
  73. package/dist/web/components/ListGroup.d.ts +55 -8
  74. package/dist/web/components/ListGroup.d.ts.map +1 -1
  75. package/dist/web/components/ListGroup.js +23 -19
  76. package/dist/web/components/ListGroup.js.map +1 -1
  77. package/dist/web/components/ListGroup.js.zyzz.json +1 -1
  78. package/dist/web/components/LoadingDots.js.zyzz.json +1 -1
  79. package/dist/web/components/Menu.css +1 -1
  80. package/dist/web/components/Menu.js.zyzz.json +1 -1
  81. package/dist/web/components/NetworkLogo.js.zyzz.json +1 -1
  82. package/dist/web/components/NumberField.js.zyzz.json +1 -1
  83. package/dist/web/components/OtpField.css +1 -1
  84. package/dist/web/components/OtpField.js.zyzz.json +1 -1
  85. package/dist/web/components/Pagination.css +1 -1
  86. package/dist/web/components/Pagination.js.zyzz.json +1 -1
  87. package/dist/web/components/Popover.js.zyzz.json +1 -1
  88. package/dist/web/components/Progress.js.zyzz.json +1 -1
  89. package/dist/web/components/Qr.js.zyzz.json +1 -1
  90. package/dist/web/components/RadioGroup.js.zyzz.json +1 -1
  91. package/dist/web/components/SecretValue.css +1 -1
  92. package/dist/web/components/SecretValue.js.zyzz.json +1 -1
  93. package/dist/web/components/Select.css +1 -1
  94. package/dist/web/components/Select.d.ts.map +1 -1
  95. package/dist/web/components/Select.js +2 -2
  96. package/dist/web/components/Select.js.map +1 -1
  97. package/dist/web/components/Select.js.zyzz.json +1 -1
  98. package/dist/web/components/Separator.js.zyzz.json +1 -1
  99. package/dist/web/components/SidebarMenu.css +1 -1
  100. package/dist/web/components/SidebarMenu.d.ts +52 -0
  101. package/dist/web/components/SidebarMenu.d.ts.map +1 -1
  102. package/dist/web/components/SidebarMenu.js +47 -16
  103. package/dist/web/components/SidebarMenu.js.map +1 -1
  104. package/dist/web/components/SidebarMenu.js.zyzz.json +1 -1
  105. package/dist/web/components/Skeleton.css +1 -1
  106. package/dist/web/components/Skeleton.js.zyzz.json +1 -1
  107. package/dist/web/components/Slider.js.zyzz.json +1 -1
  108. package/dist/web/components/Snippet.js.zyzz.json +1 -1
  109. package/dist/web/components/Spinner.js.zyzz.json +1 -1
  110. package/dist/web/components/SplitBar.js.zyzz.json +1 -1
  111. package/dist/web/components/SplitButton.js.zyzz.json +1 -1
  112. package/dist/web/components/Stack.js.zyzz.json +1 -1
  113. package/dist/web/components/StatusChip.css +1 -1
  114. package/dist/web/components/StatusChip.js.zyzz.json +1 -1
  115. package/dist/web/components/StatusRow.js.zyzz.json +1 -1
  116. package/dist/web/components/Stepper.js.zyzz.json +1 -1
  117. package/dist/web/components/Surface.css +1 -1
  118. package/dist/web/components/Surface.d.ts +85 -0
  119. package/dist/web/components/Surface.d.ts.map +1 -1
  120. package/dist/web/components/Surface.js +6 -3
  121. package/dist/web/components/Surface.js.map +1 -1
  122. package/dist/web/components/Surface.js.zyzz.json +1 -1
  123. package/dist/web/components/Switch.js.zyzz.json +1 -1
  124. package/dist/web/components/Table.css +1 -1
  125. package/dist/web/components/Table.js.zyzz.json +1 -1
  126. package/dist/web/components/Tabs.js.zyzz.json +1 -1
  127. package/dist/web/components/TempoLogo.js.zyzz.json +1 -1
  128. package/dist/web/components/TextInput.js.zyzz.json +1 -1
  129. package/dist/web/components/Textarea.css +1 -1
  130. package/dist/web/components/Textarea.js.zyzz.json +1 -1
  131. package/dist/web/components/Toast.css +1 -1
  132. package/dist/web/components/Toast.js.zyzz.json +1 -1
  133. package/dist/web/components/Token.js.zyzz.json +1 -1
  134. package/dist/web/components/TokenLogo.js.zyzz.json +1 -1
  135. package/dist/web/components/Tooltip.js.zyzz.json +1 -1
  136. package/dist/web/components/VerificationCode.css +1 -1
  137. package/dist/web/components/VerificationCode.js.zyzz.json +1 -1
  138. package/dist/web/components/Visual.js.zyzz.json +1 -1
  139. package/dist/web/components/internal/Field.css +1 -1
  140. package/dist/web/components/internal/Field.js.zyzz.json +1 -1
  141. package/dist/web/components/internal/InputGroup.css +1 -1
  142. package/dist/web/components/internal/InputGroup.js.zyzz.json +1 -1
  143. package/dist/web/components/internal/Listbox.css +1 -1
  144. package/dist/web/components/internal/Listbox.js.zyzz.json +1 -1
  145. package/dist/web/components/internal/TabBar.d.ts +33 -0
  146. package/dist/web/components/internal/TabBar.d.ts.map +1 -0
  147. package/dist/web/components/internal/TabBar.js +30 -0
  148. package/dist/web/components/internal/TabBar.js.map +1 -0
  149. package/dist/web/components/internal/Text.d.ts +9 -0
  150. package/dist/web/components/internal/Text.d.ts.map +1 -0
  151. package/dist/web/components/internal/Text.js +35 -0
  152. package/dist/web/components/internal/Text.js.map +1 -0
  153. package/dist/web/index.js.zyzz.json +1 -1
  154. package/dist/web/vars.d.ts +26 -3
  155. package/dist/web/vars.d.ts.map +1 -1
  156. package/dist/web/vars.js.map +1 -1
  157. package/dist/web/vars.js.zyzz.json +1 -1
  158. package/dist/web/zyzz.config.d.ts +130 -15
  159. package/dist/web/zyzz.config.d.ts.map +1 -1
  160. package/dist/web/zyzz.config.js.zyzz.json +1 -1
  161. package/package.json +1 -1
  162. package/src/web/components/Alert.tsx +25 -5
  163. package/src/web/components/AppShell.tsx +273 -8
  164. package/src/web/components/Badge.tsx +4 -2
  165. package/src/web/components/Button.tsx +14 -10
  166. package/src/web/components/Dialog.tsx +7 -1
  167. package/src/web/components/ListGroup.tsx +95 -14
  168. package/src/web/components/Select.tsx +2 -0
  169. package/src/web/components/SidebarMenu.tsx +97 -3
  170. package/src/web/components/Surface.tsx +105 -1
  171. package/src/web/components/internal/TabBar.ts +52 -0
  172. package/src/web/components/internal/Text.ts +39 -0
  173. package/src/web/vars.ts +20 -7
@@ -5,7 +5,7 @@ import { useRender } from '@base-ui/react/use-render'
5
5
  import type { ComponentPropsWithRef, MouseEvent, ReactNode } from 'react'
6
6
  import type * as zyzz from 'zyzz'
7
7
  import { ChevronRight } from '../icons/ChevronRight.js'
8
- import { style, variants } from '../zyzz.config.js'
8
+ import { style, variants, vars } from '../zyzz.config.js'
9
9
  import * as RowDisabled from './internal/RowDisabled.js'
10
10
  import * as RowsSize from './internal/RowsSize.js'
11
11
 
@@ -262,11 +262,13 @@ export namespace ListGroup {
262
262
 
263
263
  /**
264
264
  * `variant` sets the container. `plain` rows have none and line up with the content around them.
265
- * `boxed` rows sit in a bordered block with 16px sides and 8px above and below. `spaced` rows
265
+ * `boxed` rows sit in a bordered block with 16px sides and 8px above and below, and a row's
266
+ * hover fill reaches the border. `spaced` rows
266
267
  * sit in the same block with 16px all round, 12px apart, with 14px text and no row padding.
267
268
  *
268
269
  * Rows are 36px with 12px text, which fits a 32px control. `dividers={false}` drops the line
269
270
  * between rows, which `spaced` never draws. Variants default to `dividers`, `variant="plain"`.
271
+ * Inside a `Surface`, `plain` rows reach its edges and pad by its padding.
270
272
  */
271
273
  export function Root(props: Root.Props) {
272
274
  const { className, dividers, variant, vars, ...attributes } = props
@@ -275,6 +277,8 @@ export namespace ListGroup {
275
277
  <RowsSize.Context value>
276
278
  <div
277
279
  {...attributes}
280
+ data-list-group=""
281
+ data-variant={variant ?? 'plain'}
278
282
  {...styles.root({ className, dividers, style: props.style, variant, vars })}
279
283
  />
280
284
  </RowsSize.Context>
@@ -291,6 +295,8 @@ export namespace ListGroup {
291
295
  * apart, such as an `Avatar`, a `ListGroup.Info` and a trailing `Button`. A pressable row's hover
292
296
  * fill covers the row, flush with its content. A static row that holds a control, such as a Copy
293
297
  * `Button`, takes the same fill on hover and stays static, so the control is the only target.
298
+ * A `Select` in a row hugs its value at the row's end, in the row's type, 32px tall and without a
299
+ * fill of its own.
294
300
  *
295
301
  * A row with a `value` is a radio of the `ListGroup.RadioGroup` around it, in a 12px block with
296
302
  * 16px sides. A press anywhere on the row selects it, and a 16px ring at its end shows the
@@ -443,6 +449,30 @@ namespace styles {
443
449
  minWidth: '0',
444
450
  padding: '0',
445
451
  textAlign: 'start',
452
+ selectors: {
453
+ // A select in a row reads as the row's value: it hugs its value at the end in the row's
454
+ // type, fits the row's 32px control height, and draws no fill, since the row draws the
455
+ // hover. A disabled or invalid trigger keeps its own fill. Its box ends on the content
456
+ // edge, so it never overflows the container, and its chevron sits 8px inside its focus
457
+ // ring. `:is(*, :focus-visible)` outweighs the trigger's focus padding, so focus never moves
458
+ // the value. `Select.Trigger` marks itself, so text-input comboboxes keep their field and a
459
+ // trigger rendered as another element still matches.
460
+ '& [data-select-trigger][role="combobox"]:is(*, :focus-visible)': {
461
+ flex: '0 1 auto !custom',
462
+ font: 'inherit !custom',
463
+ height: '32',
464
+ letterSpacing: 'inherit !custom',
465
+ marginInlineStart: 'auto !custom',
466
+ minWidth: '0',
467
+ paddingInline: '8',
468
+ width: 'auto !custom',
469
+ },
470
+ '& [data-select-trigger]:not(:disabled, [data-disabled], [data-invalid], [aria-invalid="true"])':
471
+ {
472
+ backgroundColor: 'transparent !custom',
473
+ },
474
+ '& [data-select-trigger] > [data-select-value]': { flex: '0 1 auto !custom' },
475
+ },
446
476
  },
447
477
  { id: 'tds/web/list-group/content' },
448
478
  )
@@ -626,9 +656,9 @@ namespace styles {
626
656
  // A row with a meter pads 10px, which centers a 24px agent logo in 44px.
627
657
  '& [data-divider]:has([role="meter"])': { paddingBlock: '10' },
628
658
  // A hovered row's fill would otherwise sit between two visible separators.
629
- '& [data-divider]:is(a[href], button, [role="button"], :has(a[href], button, input, select, textarea)):not(:disabled, [data-disabled]):hover::before':
659
+ '& [data-divider]:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not(:disabled, [data-disabled]):hover::before':
630
660
  { display: 'none' },
631
- '& [data-divider]:is(a[href], button, [role="button"], :has(a[href], button, input, select, textarea)):not(:disabled, [data-disabled]):hover + [data-divider]::before':
661
+ '& [data-divider]:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not(:disabled, [data-disabled]):hover + [data-divider]::before':
632
662
  { display: 'none' },
633
663
  },
634
664
  },
@@ -646,6 +676,7 @@ namespace styles {
646
676
  borderRadius: 'sm',
647
677
  borderStyle: 'solid',
648
678
  borderWidth: 'regular',
679
+ isolation: 'isolate !custom',
649
680
  paddingBlock: '8',
650
681
  paddingInline: '16',
651
682
  selectors: {
@@ -653,6 +684,39 @@ namespace styles {
653
684
  '& > [data-divider]:first-child:has([data-info][data-size="large"])': {
654
685
  paddingBlockStart: '0',
655
686
  },
687
+ // A row's fill is a layer behind the row that reaches the border across the block's
688
+ // 16px sides, and its 8px above the first row and below the last. The row keeps its
689
+ // place, and the fill rounds inside the border at the block's corners.
690
+ '& > [data-divider]::after': {
691
+ backgroundColor: 'transparent !custom',
692
+ content: '"" !custom',
693
+ insetBlock: '0',
694
+ insetInline: '-16',
695
+ position: 'absolute',
696
+ transitionDuration: '160',
697
+ transitionProperty: 'background-color',
698
+ transitionTimingFunction: 'snappy',
699
+ zIndex: '-1 !custom',
700
+ },
701
+ '& > [data-divider]:first-child::after': {
702
+ borderStartEndRadius: `calc(${vars.radius.sm} - 1px) !custom`,
703
+ borderStartStartRadius: `calc(${vars.radius.sm} - 1px) !custom`,
704
+ insetBlockStart: '-8',
705
+ },
706
+ '& > [data-divider]:last-child::after': {
707
+ borderEndEndRadius: `calc(${vars.radius.sm} - 1px) !custom`,
708
+ borderEndStartRadius: `calc(${vars.radius.sm} - 1px) !custom`,
709
+ insetBlockEnd: '-8',
710
+ },
711
+ '& > [data-divider]:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not(:disabled, [data-disabled], [data-radio]):hover::after':
712
+ { backgroundColor: 'container.regular' },
713
+ '& > [data-divider]:is(a[href], button, [role="button"]):not(:disabled, [data-disabled]):active::after':
714
+ {
715
+ backgroundColor: 'container.strong',
716
+ },
717
+ },
718
+ '@media (prefers-reduced-motion: reduce)': {
719
+ selectors: { '& > [data-divider]::after': { transitionProperty: 'none' } },
656
720
  },
657
721
  },
658
722
  plain: {},
@@ -703,22 +767,39 @@ namespace styles {
703
767
  textAlign: 'start',
704
768
  textDecoration: 'none !custom',
705
769
  transitionDuration: '160',
706
- transitionProperty: 'background-color, opacity, outline-color, outline-offset',
770
+ transitionProperty: 'background-color, box-shadow, opacity, outline-color, outline-offset',
707
771
  transitionTimingFunction: 'snappy',
708
772
  width: '100% !custom',
709
773
  selectors: {
710
- // The fill covers the row, flush with its content, as Figma's List Row draws it. A static
711
- // row that holds a control takes the same fill on hover, without becoming pressable.
712
- '&:is(a[href], button, [role="button"], :has(a[href], button, input, select, textarea)):not([data-radio])':
774
+ // The row keeps Figma's List Row geometry, flush with its content, and its fill bleeds
775
+ // 12px past the content at each side. A shadow on each side draws the bleed: an outer
776
+ // shadow never paints under the row, so a translucent fill never doubles. A dialog body
777
+ // scrolls and clips at its 4px padding, so the bleed there is 4px, except in a surface,
778
+ // whose rows draw none. A boxed group draws the fill behind its rows. A static row that holds an enabled control takes the same fill
779
+ // on hover, without becoming pressable.
780
+ '&:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not([data-radio])':
713
781
  { borderRadius: 'xs' },
714
- '&:is(a[href], button, [role="button"], :has(a[href], button, input, select, textarea)):not(:disabled, [data-disabled], [data-radio]):hover':
715
- { backgroundColor: 'container.regular' },
782
+ '&:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not(:disabled, [data-disabled], [data-radio], [data-variant="boxed"] > *):hover':
783
+ {
784
+ backgroundColor: 'container.regular',
785
+ boxShadow: `-12px 0 0 0 ${vars.color.container.regular}, 12px 0 0 0 ${vars.color.container.regular} !custom`,
786
+ },
716
787
  '&:is(a[href], button, [role="button"]):not(:disabled, [data-disabled])': {
717
788
  cursor: 'pointer',
718
789
  },
719
- '&:is(a[href], button, [role="button"]):not(:disabled, [data-disabled]):active': {
720
- backgroundColor: 'container.strong',
721
- },
790
+ '&:is(a[href], button, [role="button"]):not(:disabled, [data-disabled], [data-variant="boxed"] > *):active':
791
+ {
792
+ backgroundColor: 'container.strong',
793
+ boxShadow: `-12px 0 0 0 ${vars.color.container.strong}, 12px 0 0 0 ${vars.color.container.strong} !custom`,
794
+ },
795
+ ':is([data-dialog-body], [data-dialog-body] :not([data-surface-size])) > [data-list-group]:not([data-variant="boxed"]) > &:is(a[href], button, [role="button"], :has(:where(a[href], :is(button, input, select, textarea, [data-select-trigger]):not(:disabled, [data-disabled])))):not(:disabled, [data-disabled], [data-radio]):hover':
796
+ {
797
+ boxShadow: `-4px 0 0 0 ${vars.color.container.regular}, 4px 0 0 0 ${vars.color.container.regular} !custom`,
798
+ },
799
+ ':is([data-dialog-body], [data-dialog-body] :not([data-surface-size])) > [data-list-group]:not([data-variant="boxed"]) > &:is(a[href], button, [role="button"]):not(:disabled, [data-disabled]):active':
800
+ {
801
+ boxShadow: `-4px 0 0 0 ${vars.color.container.strong}, 4px 0 0 0 ${vars.color.container.strong} !custom`,
802
+ },
722
803
  '&:is(a[href], button, [role="button"]):focus-visible': {
723
804
  outlineColor: 'border.focus',
724
805
  outlineOffset: '-2',
@@ -758,7 +839,7 @@ namespace styles {
758
839
  },
759
840
  },
760
841
  '@media (prefers-reduced-motion: reduce)': {
761
- transitionProperty: 'background-color, opacity, outline-color',
842
+ transitionProperty: 'background-color, box-shadow, opacity, outline-color',
762
843
  selectors: {
763
844
  '&[data-radio]': { transitionProperty: 'background-color, border-color, opacity' },
764
845
  '&[data-radio]:active': { scale: 'none !custom' },
@@ -273,6 +273,7 @@ export namespace Select {
273
273
  })}
274
274
  aria-invalid={invalid || attributes['aria-invalid']}
275
275
  data-invalid={invalid || undefined}
276
+ data-select-trigger=""
276
277
  />
277
278
  )
278
279
  }
@@ -296,6 +297,7 @@ export namespace Select {
296
297
  return (
297
298
  <BaseUi.Select.Value
298
299
  {...attributes}
300
+ data-select-value=""
299
301
  {...styles.value({ className, style: props.style, vars })}
300
302
  />
301
303
  )
@@ -13,6 +13,8 @@ import type * as zyzz from 'zyzz'
13
13
  import { ChevronDown } from '../icons/ChevronDown.js'
14
14
  import { ChevronRight } from '../icons/ChevronRight.js'
15
15
  import { style, variants } from '../zyzz.config.js'
16
+ import * as TabBar from './internal/TabBar.js'
17
+ import * as Text from './internal/Text.js'
16
18
 
17
19
  // A group names itself after the ID of the label inside it, which a caller can set.
18
20
  type GroupContext = { id: string; setLabel: (id: string | undefined) => void }
@@ -180,15 +182,44 @@ export namespace SidebarMenu {
180
182
  * Renders a link by default. Pass `render` to use a router link. `active` marks the current page
181
183
  * with `aria-current="page"`, and sets its label in the strong weight. Keyboard focus draws a
182
184
  * 2px ring inside the row and moves its content 2px inward.
185
+ *
186
+ * Inside an `AppShell`, `tabBar` repeats the item in the bottom bar below 768px, with the same
187
+ * `href`, `render` and handlers. In an `AppShell` with `collapse`, the sidebar shows only the
188
+ * first element of each item from 768px to 1023px, such as its icon, and keeps its text as the
189
+ * accessible name.
190
+ *
191
+ * @example
192
+ * <SidebarMenu.Item active href="/" tabBar="tab">
193
+ * <Home />
194
+ * Home
195
+ * </SidebarMenu.Item>
183
196
  */
184
197
  export function Item(props: Item.Props) {
185
- const { active = false, className, ref, render, vars, ...attributes } = props
198
+ const { active = false, className, ref, render, tabBar, vars, ...attributes } = props
199
+ const store = use(TabBar.Context)
200
+ const id = useId()
201
+
202
+ // Registers on every render, so the bottom bar repeats the current props of the item.
203
+ useLayoutEffect(() => {
204
+ if (!store) return
205
+ if (!tabBar) return store.delete(id)
206
+
207
+ const { children, style: _, ...rest } = attributes
208
+ store.set(id, { active, attributes: rest, children, render, variant: tabBar })
209
+ })
210
+
211
+ useLayoutEffect(() => {
212
+ if (!store) return
213
+ return () => store.delete(id)
214
+ }, [id, store])
186
215
 
187
216
  return useRender({
188
217
  defaultTagName: 'a',
189
218
  props: {
190
219
  ...attributes,
191
220
  'aria-current': active ? 'page' : undefined,
221
+ // Text renders in a span, so the collapsed sidebar can hide it as an element.
222
+ children: Text.wrap(attributes.children),
192
223
  ...styles.item({ active, className, style: props.style, vars }),
193
224
  },
194
225
  ref,
@@ -200,6 +231,12 @@ export namespace SidebarMenu {
200
231
  export type Props = Omit<useRender.ComponentProps<'a'>, 'color'> &
201
232
  Omit<zyzz.Props.Variants<typeof styles.item>, 'active'> & {
202
233
  active?: boolean | undefined
234
+ /**
235
+ * Repeats the item in the bottom bar of an `AppShell` below 768px: `tab` as an icon in the
236
+ * tab bar, or `action` as the floating action button beside it. Give the tab bar up to
237
+ * three items and one action. Absent, the item stays in the sidebar drawer only.
238
+ */
239
+ tabBar?: TabBar.Variant | undefined
203
240
  }
204
241
  }
205
242
 
@@ -293,7 +330,8 @@ export namespace SidebarMenu {
293
330
  {...attributes}
294
331
  {...styles.item({ className, style: props.style, vars })}
295
332
  >
296
- {children}
333
+ {/* Text renders in a span, so the collapsed sidebar can hide it as an element. */}
334
+ {Text.wrap(children)}
297
335
  <ChevronRight {...styles.chevron()} />
298
336
  </BaseUi.Collapsible.Trigger>
299
337
  )
@@ -330,6 +368,9 @@ namespace styles {
330
368
  selectors: {
331
369
  ':hover > &, [data-popup-open] > &': { color: 'content.primary' },
332
370
  },
371
+ '@media (min-width: 768px) and (max-width: 1023px)': {
372
+ selectors: { '[data-app-shell-rail] &': { display: 'none !custom' } },
373
+ },
333
374
  },
334
375
  { id: 'tds/web/sidebar-menu/account-chevron' },
335
376
  )
@@ -365,6 +406,15 @@ namespace styles {
365
406
  export const accountRow = variants(
366
407
  {
367
408
  base: {
409
+ '@media (min-width: 768px) and (max-width: 1023px)': {
410
+ selectors: {
411
+ '[data-app-shell-rail] &': {
412
+ columnGap: '0',
413
+ justifyContent: 'center',
414
+ paddingInline: '0',
415
+ },
416
+ },
417
+ },
368
418
  alignItems: 'center',
369
419
  appearance: 'none !custom',
370
420
  backgroundColor: 'transparent !custom',
@@ -413,7 +463,24 @@ namespace styles {
413
463
  )
414
464
 
415
465
  export const accountText = style(
416
- { display: 'flex', flex: '1 !custom', flexDirection: 'column', minWidth: '0' },
466
+ {
467
+ display: 'flex',
468
+ flex: '1 !custom',
469
+ flexDirection: 'column',
470
+ minWidth: '0',
471
+ '@media (min-width: 768px) and (max-width: 1023px)': {
472
+ selectors: {
473
+ '[data-app-shell-rail] &': {
474
+ clipPath: 'inset(50%) !custom',
475
+ height: '1px !custom',
476
+ overflow: 'hidden',
477
+ position: 'absolute',
478
+ whiteSpace: 'nowrap',
479
+ width: '1px !custom',
480
+ },
481
+ },
482
+ },
483
+ },
417
484
  { id: 'tds/web/sidebar-menu/account-text' },
418
485
  )
419
486
 
@@ -457,6 +524,10 @@ namespace styles {
457
524
  paddingInline: '8',
458
525
  selectors: { '[data-sidebar-menu-size="large"] &': { paddingInline: '12' } },
459
526
  typography: 'body.b3',
527
+ // The collapsed sidebar keeps the label for the name of its group without painting it.
528
+ '@media (min-width: 768px) and (max-width: 1023px)': {
529
+ selectors: { '[data-app-shell-rail] &': { display: 'none !custom' } },
530
+ },
460
531
  },
461
532
  },
462
533
  { id: 'tds/web/sidebar-menu/group-label' },
@@ -523,6 +594,26 @@ namespace styles {
523
594
  transitionProperty: 'background-color, color, outline-color !custom',
524
595
  selectors: { '&:active': { scale: 'none !custom' } },
525
596
  },
597
+ // The collapsed sidebar centers the first element, such as the icon, in a 40px row. Its
598
+ // other elements and its text stay in the accessible name.
599
+ '@media (min-width: 768px) and (max-width: 1023px)': {
600
+ selectors: {
601
+ '[data-app-shell-rail] &': {
602
+ columnGap: '0',
603
+ justifyContent: 'center',
604
+ paddingInline: '0',
605
+ position: 'relative',
606
+ },
607
+ '[data-app-shell-rail] & > :not(:first-child)': {
608
+ clipPath: 'inset(50%) !custom',
609
+ height: '1px !custom',
610
+ overflow: 'hidden',
611
+ position: 'absolute',
612
+ whiteSpace: 'nowrap',
613
+ width: '1px !custom',
614
+ },
615
+ },
616
+ },
526
617
  },
527
618
  defaultVariants: { active: false },
528
619
  variants: {
@@ -579,6 +670,9 @@ namespace styles {
579
670
  '[data-sidebar-menu-size="large"] &': { paddingTop: '4', rowGap: '4' },
580
671
  '[data-sidebar-menu-size="small"] &': { paddingInlineStart: '24' },
581
672
  },
673
+ '@media (min-width: 768px) and (max-width: 1023px)': {
674
+ selectors: { '[data-app-shell-rail] &': { paddingInlineStart: '0' } },
675
+ },
582
676
  },
583
677
  { id: 'tds/web/sidebar-menu/submenu-content' },
584
678
  )
@@ -13,6 +13,9 @@ import { variants, vars } from '../zyzz.config.js'
13
13
  * - A pressable surface should hold no other interactive controls, since nested targets conflict.
14
14
  * - A direct child `table` bleeds 12px into the padding at the sides and the bottom, as rows pad
15
15
  * their cells, so its text stays on the content edge. The surface draws no border around it.
16
+ * - A direct child `plain` `ListGroup` bleeds into the padding, so a hover fill reaches the edges.
17
+ * Each row is 44px, pads its sides by the surface's padding, and shares the height of the
18
+ * tallest row.
16
19
  *
17
20
  * @example
18
21
  * <Surface>
@@ -62,7 +65,13 @@ export namespace Surface {
62
65
  export function Header(props: Header.Props) {
63
66
  const { className, vars, ...attributes } = props
64
67
 
65
- return <div {...attributes} {...styles.header({ className, style: props.style, vars })} />
68
+ return (
69
+ <div
70
+ {...attributes}
71
+ data-surface-header=""
72
+ {...styles.header({ className, style: props.style, vars })}
73
+ />
74
+ )
66
75
  }
67
76
 
68
77
  export namespace Header {
@@ -226,6 +235,38 @@ namespace styles {
226
235
  // table clips rather than hides its overflow, so a sticky header still sticks to the
227
236
  // scrolling ancestor. The surface draws no border around the rows.
228
237
  '&:has(> table)': { borderStyle: 'none', overflow: 'clip !custom' },
238
+ // Every row takes the height of the tallest, such as a row that holds a 48px field. The
239
+ // column can shrink below a row's content, so a long value truncates inside the surface.
240
+ '& > [data-list-group][data-variant="plain"]': {
241
+ display: 'grid',
242
+ gridAutoRows: '1fr !custom',
243
+ gridTemplateColumns: 'minmax(0, 1fr) !custom',
244
+ },
245
+ // Rows are 44px, with 4px above and below a 32px control. Their fills stay square, except
246
+ // at the surface's corners, where each size rounds them inside the border.
247
+ '& > [data-list-group][data-variant="plain"] > [data-divider]': {
248
+ borderRadius: 'none',
249
+ minHeight: '44',
250
+ paddingBlock: '4',
251
+ },
252
+ // A row of a title over a description, or of a meter, keeps its own block padding.
253
+ '& > [data-list-group][data-variant="plain"] > [data-divider]:has([data-info])': {
254
+ paddingBlock: '12',
255
+ },
256
+ '& > [data-list-group][data-variant="plain"] > [data-divider]:has([role="meter"])': {
257
+ paddingBlock: '10',
258
+ },
259
+ // The rows already reach the surface's edges, so their fills draw no bleed past them.
260
+ '& > [data-list-group][data-variant="plain"] > [data-divider]:is(:hover, :active)': {
261
+ boxShadow: 'none !custom',
262
+ },
263
+ // After a header, the list rises by the 14px above the text of a one-line row, so the
264
+ // text keeps the header's 20px gap. After other content, the list keeps its place, so its
265
+ // fills never cover that content.
266
+ '& > [data-surface-header] + [data-list-group][data-variant="plain"]:has(> [data-divider])':
267
+ {
268
+ marginBlockStart: 'calc((1rem - 2.75rem) / 2) !custom',
269
+ },
229
270
  // A table bleeds by the 12px that its rows pad their edge cells, so the rows sit 12px
230
271
  // inside the padding and their text on the content edge.
231
272
  '& > table': {
@@ -267,12 +308,35 @@ namespace styles {
267
308
  variants: {
268
309
  // Each size pads by its own radius, and they scale together. A direct child table with a
269
310
  // header bleeds into the top padding, and its header cells take back exactly the padding.
311
+ // A direct child list bleeds into the padding at the sides, and above and below it at the
312
+ // surface's edges once it holds a row, so an empty list keeps the padding. Its rows take
313
+ // back the padding at the sides.
270
314
  size: {
271
315
  large: {
272
316
  borderRadius: 'md',
273
317
  padding: '24',
274
318
  selectors: {
275
319
  '& > table:first-child:has(> thead)': { marginBlockStart: '-24' },
320
+ '& > [data-list-group][data-variant="plain"]': { marginInline: '-24' },
321
+ '& > [data-list-group][data-variant="plain"]:first-child:has(> [data-divider])': {
322
+ marginBlockStart: '-24',
323
+ },
324
+ '& > [data-list-group][data-variant="plain"]:last-child:has(> [data-divider])': {
325
+ marginBlockEnd: '-24',
326
+ },
327
+ '& > [data-list-group][data-variant="plain"]:first-child > [data-divider]:first-child':
328
+ {
329
+ borderStartEndRadius: `calc(${vars.radius.md} - 1px) !custom`,
330
+ borderStartStartRadius: `calc(${vars.radius.md} - 1px) !custom`,
331
+ },
332
+ '& > [data-list-group][data-variant="plain"]:last-child > [data-divider]:last-child':
333
+ {
334
+ borderEndEndRadius: `calc(${vars.radius.md} - 1px) !custom`,
335
+ borderEndStartRadius: `calc(${vars.radius.md} - 1px) !custom`,
336
+ },
337
+ '& > [data-list-group][data-variant="plain"] > [data-divider]': {
338
+ paddingInline: '24',
339
+ },
276
340
  '& > table:first-child > thead > tr:first-child > th': {
277
341
  paddingBlockStart: '24',
278
342
  },
@@ -283,6 +347,26 @@ namespace styles {
283
347
  padding: '16',
284
348
  selectors: {
285
349
  '& > table:first-child:has(> thead)': { marginBlockStart: '-16' },
350
+ '& > [data-list-group][data-variant="plain"]': { marginInline: '-16' },
351
+ '& > [data-list-group][data-variant="plain"]:first-child:has(> [data-divider])': {
352
+ marginBlockStart: '-16',
353
+ },
354
+ '& > [data-list-group][data-variant="plain"]:last-child:has(> [data-divider])': {
355
+ marginBlockEnd: '-16',
356
+ },
357
+ '& > [data-list-group][data-variant="plain"]:first-child > [data-divider]:first-child':
358
+ {
359
+ borderStartEndRadius: `calc(${vars.radius.sm} - 1px) !custom`,
360
+ borderStartStartRadius: `calc(${vars.radius.sm} - 1px) !custom`,
361
+ },
362
+ '& > [data-list-group][data-variant="plain"]:last-child > [data-divider]:last-child':
363
+ {
364
+ borderEndEndRadius: `calc(${vars.radius.sm} - 1px) !custom`,
365
+ borderEndStartRadius: `calc(${vars.radius.sm} - 1px) !custom`,
366
+ },
367
+ '& > [data-list-group][data-variant="plain"] > [data-divider]': {
368
+ paddingInline: '16',
369
+ },
286
370
  '& > table:first-child > thead > tr:first-child > th': {
287
371
  paddingBlockStart: '16',
288
372
  },
@@ -293,6 +377,26 @@ namespace styles {
293
377
  padding: '12',
294
378
  selectors: {
295
379
  '& > table:first-child:has(> thead)': { marginBlockStart: '-12' },
380
+ '& > [data-list-group][data-variant="plain"]': { marginInline: '-12' },
381
+ '& > [data-list-group][data-variant="plain"]:first-child:has(> [data-divider])': {
382
+ marginBlockStart: '-12',
383
+ },
384
+ '& > [data-list-group][data-variant="plain"]:last-child:has(> [data-divider])': {
385
+ marginBlockEnd: '-12',
386
+ },
387
+ '& > [data-list-group][data-variant="plain"]:first-child > [data-divider]:first-child':
388
+ {
389
+ borderStartEndRadius: `calc(${vars.radius.xs} - 1px) !custom`,
390
+ borderStartStartRadius: `calc(${vars.radius.xs} - 1px) !custom`,
391
+ },
392
+ '& > [data-list-group][data-variant="plain"]:last-child > [data-divider]:last-child':
393
+ {
394
+ borderEndEndRadius: `calc(${vars.radius.xs} - 1px) !custom`,
395
+ borderEndStartRadius: `calc(${vars.radius.xs} - 1px) !custom`,
396
+ },
397
+ '& > [data-list-group][data-variant="plain"] > [data-divider]': {
398
+ paddingInline: '12',
399
+ },
296
400
  '& > table:first-child > thead > tr:first-child > th': {
297
401
  paddingBlockStart: '12',
298
402
  },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Holds the `SidebarMenu.Item` entries that `AppShell` repeats in its bottom bar below 768px. The
3
+ * store lives outside React state, so an item that registers on every render updates the bar
4
+ * without rendering the shell again.
5
+ * @module
6
+ */
7
+ import type { useRender } from '@base-ui/react/use-render'
8
+ import { createContext, type ReactNode } from 'react'
9
+
10
+ /** The props of a `SidebarMenu.Item` that the bottom bar repeats, without its sidebar styles. */
11
+ export type Entry = {
12
+ active: boolean
13
+ attributes: Readonly<Record<string, unknown>>
14
+ children: ReactNode
15
+ render: useRender.ComponentProps<'a'>['render']
16
+ variant: Variant
17
+ }
18
+
19
+ /** `tab` renders an icon in the tab bar, and `action` the floating action button beside it. */
20
+ export type Variant = 'action' | 'tab'
21
+
22
+ export type Store = ReturnType<typeof create>
23
+
24
+ export const Context = createContext<Store | undefined>(undefined)
25
+
26
+ /** Creates the store of one `AppShell.Root`. Entries keep the order in which they first register. */
27
+ export function create() {
28
+ const entries = new Map<string, Entry>()
29
+ const listeners = new Set<() => void>()
30
+ let snapshot: readonly (readonly [string, Entry])[] = []
31
+
32
+ const notify = () => {
33
+ snapshot = [...entries]
34
+ for (const listener of listeners) listener()
35
+ }
36
+
37
+ return {
38
+ delete(id: string) {
39
+ if (!entries.delete(id)) return
40
+ notify()
41
+ },
42
+ set(id: string, entry: Entry) {
43
+ entries.set(id, entry)
44
+ notify()
45
+ },
46
+ snapshot: () => snapshot,
47
+ subscribe(listener: () => void) {
48
+ listeners.add(listener)
49
+ return () => listeners.delete(listener)
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Wraps each run of adjacent string and number children in one span, so a rule can hide a label
3
+ * as an element while the label still reads as one run, such as `Inbox ({count})`.
4
+ * @module
5
+ */
6
+ import { Children, createElement, type HTMLAttributes, type ReactNode } from 'react'
7
+
8
+ /** Returns `children` with each run of adjacent text wrapped in a span with `props`. */
9
+ export function wrap(children: ReactNode, props: HTMLAttributes<HTMLSpanElement> = {}) {
10
+ const result: ReactNode[] = []
11
+ let run: (number | string)[] = []
12
+
13
+ // A run of only whitespace, such as the space between two elements on one JSX line, renders no
14
+ // flex item in a flex row, so it passes through unwrapped and adds no gap.
15
+ const flush = () => {
16
+ if (run.length === 0) return
17
+ if (run.every((part) => typeof part === 'string' && part.trim() === '')) {
18
+ result.push(...run)
19
+ run = []
20
+ return
21
+ }
22
+
23
+ result.push(createElement('span', { ...props, key: result.length }, ...run))
24
+ run = []
25
+ }
26
+
27
+ for (const child of Children.toArray(children)) {
28
+ if (typeof child === 'string' || typeof child === 'number') {
29
+ run.push(child)
30
+ continue
31
+ }
32
+
33
+ flush()
34
+ result.push(child)
35
+ }
36
+
37
+ flush()
38
+ return result
39
+ }