@dxos/react-ui 0.8.4-main.a4bbb77 → 0.8.4-main.ae835ea

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 (51) hide show
  1. package/dist/lib/browser/{chunk-LBCJC75U.mjs → chunk-HUZZ56DW.mjs} +165 -115
  2. package/dist/lib/browser/chunk-HUZZ56DW.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +3 -1
  4. package/dist/lib/browser/index.mjs.map +1 -1
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +44 -16
  7. package/dist/lib/browser/testing/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/{chunk-QTUGGUCB.mjs → chunk-OJLL6E2Z.mjs} +165 -115
  9. package/dist/lib/node-esm/chunk-OJLL6E2Z.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +3 -1
  11. package/dist/lib/node-esm/index.mjs.map +1 -1
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +44 -16
  14. package/dist/lib/node-esm/testing/index.mjs.map +4 -4
  15. package/dist/types/src/components/Icon/Icon.stories.d.ts +17 -0
  16. package/dist/types/src/components/Icon/Icon.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Lists/Treegrid.d.ts.map +1 -1
  18. package/dist/types/src/components/Main/Main.d.ts +1 -10
  19. package/dist/types/src/components/Main/Main.d.ts.map +1 -1
  20. package/dist/types/src/components/Popover/Popover.d.ts.map +1 -1
  21. package/dist/types/src/components/Toolbar/Toolbar.d.ts +9 -5
  22. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  23. package/dist/types/src/hooks/useVisualViewport.d.ts +2 -2
  24. package/dist/types/src/hooks/useVisualViewport.d.ts.map +1 -1
  25. package/dist/types/src/testing/decorators/index.d.ts +2 -1
  26. package/dist/types/src/testing/decorators/index.d.ts.map +1 -1
  27. package/dist/types/src/testing/decorators/withLayout.d.ts +15 -0
  28. package/dist/types/src/testing/decorators/withLayout.d.ts.map +1 -0
  29. package/dist/types/src/util/domino.d.ts +1 -1
  30. package/dist/types/src/util/domino.d.ts.map +1 -1
  31. package/dist/types/src/util/index.d.ts +1 -0
  32. package/dist/types/src/util/index.d.ts.map +1 -1
  33. package/dist/types/src/util/usePx.d.ts +8 -0
  34. package/dist/types/src/util/usePx.d.ts.map +1 -0
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +16 -15
  37. package/src/components/Icon/Icon.stories.tsx +113 -0
  38. package/src/components/Icon/Icon.tsx +1 -1
  39. package/src/components/Lists/Treegrid.tsx +57 -16
  40. package/src/components/Main/Main.tsx +16 -7
  41. package/src/components/Popover/Popover.tsx +3 -3
  42. package/src/components/Toolbar/Toolbar.tsx +16 -5
  43. package/src/hooks/useSafeArea.ts +2 -2
  44. package/src/hooks/useVisualViewport.ts +3 -3
  45. package/src/testing/decorators/index.ts +2 -1
  46. package/src/testing/decorators/withLayout.tsx +56 -0
  47. package/src/util/domino.ts +6 -4
  48. package/src/util/index.ts +1 -0
  49. package/src/util/usePx.ts +61 -0
  50. package/dist/lib/browser/chunk-LBCJC75U.mjs.map +0 -7
  51. package/dist/lib/node-esm/chunk-QTUGGUCB.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui",
3
- "version": "0.8.4-main.a4bbb77",
3
+ "version": "0.8.4-main.ae835ea",
4
4
  "description": "Low-level React components for DXOS, applying a theme to a core group of primitives",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -74,32 +74,33 @@
74
74
  "keyborg": "^2.5.0",
75
75
  "react-i18next": "^11.18.6",
76
76
  "react-remove-scroll": "^2.6.0",
77
- "@dxos/debug": "0.8.4-main.a4bbb77",
78
- "@dxos/lit-ui": "0.8.4-main.a4bbb77",
79
- "@dxos/log": "0.8.4-main.a4bbb77",
80
- "@dxos/react-hooks": "0.8.4-main.a4bbb77",
81
- "@dxos/react-input": "0.8.4-main.a4bbb77",
82
- "@dxos/react-list": "0.8.4-main.a4bbb77",
83
- "@dxos/react-ui-types": "0.8.4-main.a4bbb77",
84
- "@dxos/util": "0.8.4-main.a4bbb77"
77
+ "@dxos/debug": "0.8.4-main.ae835ea",
78
+ "@dxos/lit-ui": "0.8.4-main.ae835ea",
79
+ "@dxos/log": "0.8.4-main.ae835ea",
80
+ "@dxos/react-hooks": "0.8.4-main.ae835ea",
81
+ "@dxos/react-input": "0.8.4-main.ae835ea",
82
+ "@dxos/react-list": "0.8.4-main.ae835ea",
83
+ "@dxos/react-ui-types": "0.8.4-main.ae835ea",
84
+ "@dxos/util": "0.8.4-main.ae835ea"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@dnd-kit/core": "^6.0.5",
88
88
  "@dnd-kit/sortable": "^7.0.1",
89
89
  "@dnd-kit/utilities": "^3.2.0",
90
- "@types/react": "~19.2.0",
91
- "@types/react-dom": "~19.2.0",
90
+ "@phosphor-icons/react": "^2.1.10",
91
+ "@types/react": "~19.2.2",
92
+ "@types/react-dom": "~19.2.2",
92
93
  "react": "~19.2.0",
93
94
  "react-dom": "~19.2.0",
94
95
  "vite": "7.1.9",
95
- "@dxos/random": "0.8.4-main.a4bbb77",
96
- "@dxos/react-ui-theme": "0.8.4-main.a4bbb77",
97
- "@dxos/util": "0.8.4-main.a4bbb77"
96
+ "@dxos/random": "0.8.4-main.ae835ea",
97
+ "@dxos/react-ui-theme": "0.8.4-main.ae835ea",
98
+ "@dxos/util": "0.8.4-main.ae835ea"
98
99
  },
99
100
  "peerDependencies": {
100
101
  "react": "^19.0.0",
101
102
  "react-dom": "^19.0.0",
102
- "@dxos/react-ui-theme": "0.8.4-main.a4bbb77"
103
+ "@dxos/react-ui-theme": "0.8.4-main.ae835ea"
103
104
  },
104
105
  "publishConfig": {
105
106
  "access": "public"
@@ -0,0 +1,113 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { IconBase, type IconProps, type IconWeight } from '@phosphor-icons/react';
6
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
7
+ import React, { type FC, type ReactElement, type SVGProps, forwardRef } from 'react';
8
+
9
+ import { getSize, mx } from '@dxos/react-ui-theme';
10
+
11
+ import { withTheme } from '../../testing';
12
+
13
+ import { Icon } from './Icon';
14
+
15
+ /**
16
+ * Create icon from serializable data.
17
+ * https://github.com/phosphor-icons/react#custom-icons
18
+ * https://github.com/phosphor-icons/core/tree/main/assets
19
+ */
20
+ const createIcon = ({
21
+ name,
22
+ weights,
23
+ }: {
24
+ name: string;
25
+ weights: Record<string, SVGProps<SVGPathElement>[]>;
26
+ }): FC<IconProps> => {
27
+ const CustomIcon = forwardRef<SVGSVGElement, IconProps>((props, ref) => (
28
+ <IconBase
29
+ ref={ref}
30
+ {...props}
31
+ weights={
32
+ new Map<IconWeight, ReactElement>(
33
+ Object.entries(weights).map(
34
+ ([key, paths]) =>
35
+ [
36
+ key,
37
+ <>
38
+ {paths.map((props, i) => (
39
+ <path key={`${key}-${i}`} {...props} />
40
+ ))}
41
+ </>,
42
+ ] as [IconWeight, ReactElement],
43
+ ),
44
+ )
45
+ }
46
+ />
47
+ ));
48
+
49
+ CustomIcon.displayName = name;
50
+ return CustomIcon;
51
+ };
52
+
53
+ const DefaultStory = ({ CustomIcon }: { CustomIcon: FC<IconProps> }) => {
54
+ return (
55
+ <div className='grid grid-cols-2 gap-8'>
56
+ <CustomIcon weight={'regular'} className={mx(getSize(16))} />
57
+ <Icon icon='ph--github-logo--regular' classNames={mx(getSize(16))} />
58
+ </div>
59
+ );
60
+ };
61
+
62
+ const meta = {
63
+ title: 'ui/react-ui-core/Icon',
64
+ render: DefaultStory,
65
+ decorators: [withTheme],
66
+ parameters: {
67
+ layout: 'centered',
68
+ },
69
+ } satisfies Meta<typeof DefaultStory>;
70
+
71
+ export default meta;
72
+
73
+ type Story = StoryObj<typeof meta>;
74
+
75
+ export const Default: Story = {
76
+ args: {
77
+ CustomIcon: createIcon({
78
+ name: 'GithubLogo',
79
+ weights: {
80
+ // https://github.com/phosphor-icons/core/tree/main/assets
81
+ // <path d="M119.83,56A52,52,0,0,0,76,32a51.92,51.92,0,0,0-3.49,44.7A49.28,49.28,0,0,0,64,104v8a48,48,0,0,0,48,48h48a48,48,0,0,0,48-48v-8a49.28,49.28,0,0,0-8.51-27.3A51.92,51.92,0,0,0,196,32a52,52,0,0,0-43.83,24Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
82
+ // <path d="M104,232V192a32,32,0,0,1,32-32h0a32,32,0,0,1,32,32v40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
83
+ // <path d="M104,208H72a32,32,0,0,1-32-32A32,32,0,0,0,8,144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
84
+ regular: [
85
+ {
86
+ d: 'M119.83,56A52,52,0,0,0,76,32a51.92,51.92,0,0,0-3.49,44.7A49.28,49.28,0,0,0,64,104v8a48,48,0,0,0,48,48h48a48,48,0,0,0,48-48v-8a49.28,49.28,0,0,0-8.51-27.3A51.92,51.92,0,0,0,196,32a52,52,0,0,0-43.83,24Z',
87
+ fill: 'none',
88
+ stroke: 'currentColor',
89
+ strokeLinecap: 'round',
90
+ strokeLinejoin: 'round',
91
+ strokeWidth: '16',
92
+ },
93
+ {
94
+ d: 'M104,232V192a32,32,0,0,1,32-32h0a32,32,0,0,1,32,32v40',
95
+ fill: 'none',
96
+ stroke: 'currentColor',
97
+ strokeLinecap: 'round',
98
+ strokeLinejoin: 'round',
99
+ strokeWidth: '16',
100
+ },
101
+ {
102
+ d: 'M104,208H72a32,32,0,0,1-32-32A32,32,0,0,0,8,144',
103
+ fill: 'none',
104
+ stroke: 'currentColor',
105
+ strokeLinecap: 'round',
106
+ strokeLinejoin: 'round',
107
+ strokeWidth: '16',
108
+ },
109
+ ],
110
+ },
111
+ }),
112
+ },
113
+ };
@@ -16,7 +16,7 @@ export type IconProps = ThemedClassName<ComponentPropsWithRef<typeof Primitive.s
16
16
  };
17
17
 
18
18
  export const Icon = memo(
19
- forwardRef<SVGSVGElement, IconProps>(({ icon, classNames, size, ...props }, forwardedRef) => {
19
+ forwardRef<SVGSVGElement, IconProps>(({ icon, classNames, size = 4, ...props }, forwardedRef) => {
20
20
  const { tx } = useThemeContext();
21
21
  const href = useIconHref(icon);
22
22
  return (
@@ -2,12 +2,18 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { useArrowNavigationGroup, useFocusableGroup } from '@fluentui/react-tabster';
5
+ import { useFocusFinders } from '@fluentui/react-tabster';
6
6
  import { type Scope, createContextScope } from '@radix-ui/react-context';
7
7
  import { Primitive } from '@radix-ui/react-primitive';
8
8
  import { Slot } from '@radix-ui/react-slot';
9
9
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
10
- import React, { type CSSProperties, type ComponentPropsWithRef, forwardRef } from 'react';
10
+ import React, {
11
+ type CSSProperties,
12
+ type ComponentPropsWithRef,
13
+ type KeyboardEvent,
14
+ forwardRef,
15
+ useCallback,
16
+ } from 'react';
11
17
 
12
18
  import { useThemeContext } from '../../hooks';
13
19
  import { type ThemedClassName } from '../../util';
@@ -40,12 +46,58 @@ const TreegridRoot = forwardRef<HTMLDivElement, TreegridRootProps>(
40
46
  ({ asChild, classNames, children, style, gridTemplateColumns, ...props }, forwardedRef) => {
41
47
  const { tx } = useThemeContext();
42
48
  const Root = asChild ? Slot : Primitive.div;
43
- const arrowNavigationAttrs = useArrowNavigationGroup({ axis: 'vertical', tabbable: false, circular: true });
49
+ const { findFirstFocusable } = useFocusFinders();
50
+
51
+ const handleKeyDown = useCallback(
52
+ (event: KeyboardEvent<HTMLDivElement>) => {
53
+ switch (event.key) {
54
+ case 'ArrowDown':
55
+ case 'ArrowUp': {
56
+ const direction = event.key === 'ArrowDown' ? 'down' : 'up';
57
+ const target = event.target as HTMLElement;
58
+
59
+ // Find ancestor with data-arrow-keys containing the relevant direction.
60
+ const ancestorWithArrowKeys = target.closest(`[data-arrow-keys*="${direction}"], [data-arrow-keys="all"]`);
61
+
62
+ // If no ancestor with data-arrow-keys found, proceed with row navigation.
63
+ if (!ancestorWithArrowKeys) {
64
+ // Find the closest row
65
+ const currentRow = target.closest('[role="row"]');
66
+ if (currentRow) {
67
+ // Find the treegrid container.
68
+ const treegrid = currentRow.closest('[role="treegrid"]');
69
+ if (treegrid) {
70
+ // Get all rows in the treegrid.
71
+ const rows = Array.from(treegrid.querySelectorAll('[role="row"]'));
72
+ const currentIndex = rows.indexOf(currentRow as Element);
73
+
74
+ // Find next or previous row.
75
+ const nextIndex = direction === 'down' ? currentIndex + 1 : currentIndex - 1;
76
+ const targetRow = rows[nextIndex];
77
+
78
+ if (targetRow) {
79
+ // Focus the first focusable element in the target row.
80
+ const firstFocusable = findFirstFocusable(targetRow as HTMLElement);
81
+ if (firstFocusable) {
82
+ event.preventDefault();
83
+ firstFocusable.focus();
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ break;
90
+ }
91
+ }
92
+ props.onKeyDown?.(event);
93
+ },
94
+ [findFirstFocusable],
95
+ );
44
96
 
45
97
  return (
46
98
  <Root
47
99
  role='treegrid'
48
- {...arrowNavigationAttrs}
100
+ onKeyDown={handleKeyDown}
49
101
  {...props}
50
102
  className={tx('treegrid.root', 'treegrid', {}, classNames)}
51
103
  style={{ ...style, gridTemplateColumns }}
@@ -91,13 +143,6 @@ const TreegridRow = forwardRef<HTMLDivElement, TreegridRowScopedProps<TreegridRo
91
143
  onChange: propsOnOpenChange,
92
144
  defaultProp: defaultOpen,
93
145
  });
94
- const focusableGroupAttrs = useFocusableGroup({ tabBehavior: 'limited' });
95
- const arrowGroupAttrs = useArrowNavigationGroup({
96
- axis: 'horizontal',
97
- tabbable: false,
98
- circular: false,
99
- memorizeCurrent: false,
100
- });
101
146
 
102
147
  return (
103
148
  <TreegridRowProvider open={open} onOpenChange={onOpenChange} scope={__treegridRowScope}>
@@ -106,15 +151,11 @@ const TreegridRow = forwardRef<HTMLDivElement, TreegridRowScopedProps<TreegridRo
106
151
  aria-level={level}
107
152
  className={tx('treegrid.row', 'treegrid__row', { level }, classNames)}
108
153
  {...(parentOf && { 'aria-expanded': open, 'aria-owns': parentOf })}
109
- tabIndex={0}
110
- {...focusableGroupAttrs}
111
154
  {...props}
112
155
  id={id}
113
156
  ref={forwardedRef}
114
157
  >
115
- <div role='none' className='contents' {...arrowGroupAttrs}>
116
- {children}
117
- </div>
158
+ {children}
118
159
  </Root>
119
160
  </TreegridRowProvider>
120
161
  );
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { useFocusableGroup } from '@fluentui/react-tabster';
5
6
  import { createContext } from '@radix-ui/react-context';
6
7
  import { DialogContent, Root as DialogRoot, DialogTitle } from '@radix-ui/react-dialog';
7
8
  import { Primitive } from '@radix-ui/react-primitive';
@@ -70,7 +71,10 @@ const useLandmarkMover = (propsOnKeyDown: ComponentPropsWithoutRef<'div'>['onKey
70
71
  },
71
72
  [propsOnKeyDown],
72
73
  );
73
- const focusableGroupAttrs = window ? {} : { tabBehavior: 'limited', ignoreDefaultKeydown: { Tab: true } };
74
+
75
+ // TODO(thure): This was disconnected once before in #8818, if this should change again to support the browser
76
+ // extension, please ensure the change doesn’t break web, desktop and mobile.
77
+ const focusableGroupAttrs = useFocusableGroup({ tabBehavior: 'limited', ignoreDefaultKeydown: { Tab: true } });
74
78
 
75
79
  return {
76
80
  onKeyDown: handleKeyDown,
@@ -141,7 +145,7 @@ const MainRoot = ({
141
145
  children,
142
146
  ...props
143
147
  }: MainRootProps) => {
144
- const [isLg] = useMediaQuery('lg', { ssr: false });
148
+ const [isLg] = useMediaQuery('lg');
145
149
  const [navigationSidebarState = isLg ? 'expanded' : 'collapsed', setNavigationSidebarState] =
146
150
  useControllableState<SidebarState>({
147
151
  prop: propsNavigationSidebarState,
@@ -210,7 +214,7 @@ const MainSidebar = forwardRef<HTMLDivElement, MainSidebarProps>(
210
214
  { classNames, children, swipeToDismiss, onOpenAutoFocus, state, resizing, onStateChange, side, label, ...props },
211
215
  forwardedRef,
212
216
  ) => {
213
- const [isLg] = useMediaQuery('lg', { ssr: false });
217
+ const [isLg] = useMediaQuery('lg');
214
218
  const { tx } = useThemeContext();
215
219
  const { t } = useTranslation();
216
220
  const ref = useForwardedRef(forwardedRef);
@@ -218,10 +222,15 @@ const MainSidebar = forwardRef<HTMLDivElement, MainSidebarProps>(
218
222
  useSwipeToDismiss(swipeToDismiss ? ref : noopRef, {
219
223
  onDismiss: () => onStateChange?.('closed'),
220
224
  });
225
+ // NOTE(thure): This is a workaround for something further down the tree grabbing focus on Escape. Adding this
226
+ // intervention to `Tabs.Root` or `Tabs.Tabpenel` instances is somehow ineffectual.
221
227
  const handleKeyDown = useCallback(
222
228
  (event: KeyboardEvent<HTMLDivElement>) => {
223
- if (event.key === 'Escape') {
224
- ((event.target as HTMLDivElement).closest('[data-tabster]') as HTMLDivElement)?.focus();
229
+ const focusGroupParent = (event.target as HTMLElement).closest('[data-tabster]');
230
+ if (event.key === 'Escape' && focusGroupParent) {
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ (focusGroupParent as HTMLElement).focus();
225
234
  }
226
235
  props.onKeyDown?.(event);
227
236
  },
@@ -239,7 +248,7 @@ const MainSidebar = forwardRef<HTMLDivElement, MainSidebarProps>(
239
248
  data-state={state}
240
249
  data-resizing={resizing ? 'true' : 'false'}
241
250
  className={tx('main.sidebar', 'main__sidebar', {}, classNames)}
242
- onKeyDown={handleKeyDown}
251
+ onKeyDownCapture={handleKeyDown}
243
252
  {...(state === 'closed' && { inert: true })}
244
253
  ref={ref}
245
254
  >
@@ -329,7 +338,7 @@ MainContent.displayName = MAIN_NAME;
329
338
  type MainOverlayProps = ThemedClassName<Omit<ComponentPropsWithRef<typeof Primitive.div>, 'children'>>;
330
339
 
331
340
  const MainOverlay = forwardRef<HTMLDivElement, MainOverlayProps>(({ classNames, ...props }, forwardedRef) => {
332
- const [isLg] = useMediaQuery('lg', { ssr: false });
341
+ const [isLg] = useMediaQuery('lg');
333
342
  const { navigationSidebarState, setNavigationSidebarState, complementarySidebarState, setComplementarySidebarState } =
334
343
  useMainContext(MAIN_NAME);
335
344
  const { tx } = useThemeContext();
@@ -396,6 +396,7 @@ type PopoverContentImplElement = ElementRef<typeof PopperPrimitive.Content>;
396
396
  type FocusScopeProps = ComponentPropsWithoutRef<typeof FocusScope>;
397
397
  type DismissableLayerProps = ComponentPropsWithoutRef<typeof DismissableLayer>;
398
398
  type PopperContentProps = ThemedClassName<ComponentPropsWithoutRef<typeof PopperPrimitive.Content>>;
399
+
399
400
  interface PopoverContentImplProps
400
401
  extends Omit<PopperContentProps, 'onPlaced'>,
401
402
  Omit<DismissableLayerProps, 'onDismiss'> {
@@ -440,8 +441,7 @@ const PopoverContentImpl = forwardRef<PopoverContentImplElement, PopoverContentI
440
441
  const elevation = useElevationContext();
441
442
  const safeCollisionPadding = useSafeCollisionPadding(collisionPadding);
442
443
 
443
- // Make sure the whole tree has focus guards as our `Popover` may be
444
- // the last element in the DOM (because of the `Portal`)
444
+ // Make sure the whole tree has focus guards as our `Popover` may be the last element in the DOM (because of the `Portal`)
445
445
  useFocusGuards();
446
446
 
447
447
  return (
@@ -472,7 +472,7 @@ const PopoverContentImpl = forwardRef<PopoverContentImplElement, PopoverContentI
472
472
  ref={forwardedRef}
473
473
  style={{
474
474
  ...contentProps.style,
475
- // re-namespace exposed content custom properties
475
+ // Re-namespace exposed content custom properties.
476
476
  ...{
477
477
  '--radix-popover-content-transform-origin': 'var(--radix-popper-transform-origin)',
478
478
  '--radix-popover-content-available-width': 'var(--radix-popper-available-width)',
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ToggleGroupItemProps as ToggleGroupItemPrimitiveProps } from '@radix-ui/react-toggle-group';
6
6
  import * as ToolbarPrimitive from '@radix-ui/react-toolbar';
7
- import React, { forwardRef } from 'react';
7
+ import React, { Fragment, forwardRef } from 'react';
8
8
 
9
9
  import { useThemeContext } from '../../hooks';
10
10
  import { type ThemedClassName } from '../../util';
@@ -22,19 +22,30 @@ import {
22
22
  import { Link, type LinkProps } from '../Link';
23
23
  import { Separator, type SeparatorProps } from '../Separator';
24
24
 
25
- type ToolbarRootProps = ThemedClassName<ToolbarPrimitive.ToolbarProps> & { layoutManaged?: boolean };
25
+ type ToolbarRootProps = ThemedClassName<
26
+ ToolbarPrimitive.ToolbarProps & {
27
+ textBlockWidth?: boolean;
28
+ layoutManaged?: boolean;
29
+ disabled?: boolean;
30
+ }
31
+ >;
26
32
 
27
33
  const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarRootProps>(
28
- ({ classNames, children, layoutManaged, ...props }, forwardedRef) => {
34
+ ({ classNames, children, layoutManaged, textBlockWidth: textBlockWidthParam, disabled, ...props }, forwardedRef) => {
29
35
  const { tx } = useThemeContext();
36
+ const InnerRoot = textBlockWidthParam ? 'div' : Fragment;
37
+ const innerRootProps = textBlockWidthParam
38
+ ? { role: 'none', className: tx('toolbar.inner', 'toolbar', { layoutManaged }, classNames) }
39
+ : {};
40
+
30
41
  return (
31
42
  <ToolbarPrimitive.Root
32
43
  {...props}
33
44
  data-arrow-keys={props.orientation === 'vertical' ? 'up down' : 'left right'}
34
- className={tx('toolbar.root', 'toolbar', { layoutManaged }, classNames)}
45
+ className={tx('toolbar.root', 'toolbar', { layoutManaged, disabled }, classNames)}
35
46
  ref={forwardedRef}
36
47
  >
37
- {children}
48
+ <InnerRoot {...innerRootProps}>{children}</InnerRoot>
38
49
  </ToolbarPrimitive.Root>
39
50
  );
40
51
  },
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useCallback, useState } from 'react';
6
6
 
7
- import { useResize } from '@dxos/react-hooks';
7
+ import { useViewportResize } from '@dxos/react-hooks';
8
8
 
9
9
  export type SafeAreaPadding = Record<'top' | 'right' | 'bottom' | 'left', number>;
10
10
 
@@ -21,6 +21,6 @@ export const useSafeArea = (): SafeAreaPadding => {
21
21
  });
22
22
  }, []);
23
23
 
24
- useResize(handleResize);
24
+ useViewportResize(handleResize);
25
25
  return padding;
26
26
  };
@@ -4,9 +4,9 @@
4
4
 
5
5
  import { useCallback, useState } from 'react';
6
6
 
7
- import { useResize } from '@dxos/react-hooks';
7
+ import { useViewportResize } from '@dxos/react-hooks';
8
8
 
9
- export const useVisualViewport = (deps?: Parameters<typeof useResize>[1]) => {
9
+ export const useVisualViewport = (deps?: Parameters<typeof useViewportResize>[1]) => {
10
10
  const [width, setWidth] = useState<number | null>(null);
11
11
  const [height, setHeight] = useState<number | null>(null);
12
12
 
@@ -17,7 +17,7 @@ export const useVisualViewport = (deps?: Parameters<typeof useResize>[1]) => {
17
17
  }
18
18
  }, []);
19
19
 
20
- useResize(handleResize, deps);
20
+ useViewportResize(handleResize, deps);
21
21
 
22
22
  return { width, height };
23
23
  };
@@ -2,5 +2,6 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './withTheme';
5
+ export * from './withLayout';
6
6
  export * from './withSurfaceVariantsLayout';
7
+ export * from './withTheme';
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Decorator } from '@storybook/react';
6
+ import React, { type FC, type PropsWithChildren } from 'react';
7
+
8
+ import { type ClassNameValue, type ThemedClassName } from '@dxos/react-ui';
9
+ import { mx } from '@dxos/react-ui-theme';
10
+
11
+ export type ContainerProps = ThemedClassName<PropsWithChildren>;
12
+
13
+ export type ContainerType = 'default' | 'column';
14
+
15
+ export type WithLayoutProps =
16
+ | FC<ContainerProps>
17
+ | { classNames?: ClassNameValue; container?: ContainerType; scroll?: boolean };
18
+
19
+ /**
20
+ * Adds layout container.
21
+ */
22
+ export const withLayout =
23
+ (props: WithLayoutProps): Decorator =>
24
+ (Story) => {
25
+ if (typeof props === 'function') {
26
+ const Container = props;
27
+ return (
28
+ <Container>
29
+ <Story />
30
+ </Container>
31
+ );
32
+ }
33
+
34
+ const Container = layouts[(props as any).container as ContainerType] ?? layouts.default;
35
+ return (
36
+ <Container classNames={mx(props.classNames, props.scroll ? 'overflow-y-auto' : 'overflow-hidden')}>
37
+ <Story />
38
+ </Container>
39
+ );
40
+ };
41
+
42
+ const layouts: Record<ContainerType, FC<ContainerProps>> = {
43
+ default: ({ children, classNames }: ContainerProps) => (
44
+ <div role='none' className={mx(classNames)}>
45
+ {children}
46
+ </div>
47
+ ),
48
+
49
+ column: ({ children, classNames }: ContainerProps) => (
50
+ <div role='none' className='fixed inset-0 flex justify-center overflow-hidden bg-deckSurface'>
51
+ <div role='none' className={mx('flex flex-col is-[40rem] bg-baseSurface', classNames)}>
52
+ {children}
53
+ </div>
54
+ </div>
55
+ ),
56
+ };
@@ -29,12 +29,14 @@ export class Domino<T extends HTMLElement> {
29
29
  this._el.dataset[key] = value;
30
30
  return this;
31
31
  }
32
- style(styles: Partial<CSSStyleDeclaration>): this {
33
- Object.assign(this._el.style, styles);
32
+ attributes(attr: Record<string, string | undefined>): this {
33
+ Object.entries(attr)
34
+ .filter(([_, value]) => value !== undefined)
35
+ .map(([key, value]) => this._el.setAttribute(key, value!));
34
36
  return this;
35
37
  }
36
- attr<K extends keyof T>(key: K, value: T[K]): this {
37
- (this._el as any)[key] = value;
38
+ style(styles: Partial<CSSStyleDeclaration>): this {
39
+ Object.assign(this._el.style, styles);
38
40
  return this;
39
41
  }
40
42
  children<C extends HTMLElement>(...children: Domino<C>[]): this {
package/src/util/index.ts CHANGED
@@ -5,3 +5,4 @@
5
5
  export * from './domino';
6
6
  export * from './hasIosKeyboard';
7
7
  export type * from './ThemedClassName';
8
+ export * from './usePx';
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useCallback, useEffect, useMemo, useState } from 'react';
6
+
7
+ const getDocumentElementFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize);
8
+
9
+ /**
10
+ * React hook that converts rem values to pixels and updates when the root font size changes.
11
+ *
12
+ * @param rem The rem value to convert to pixels
13
+ * @returns The current pixel value equivalent of the rem input
14
+ */
15
+ export const usePx = (rem: number): number => {
16
+ const [fontSize, setFontSize] = useState(() => {
17
+ if (typeof document !== 'undefined') {
18
+ return getDocumentElementFontSize();
19
+ }
20
+ return 16; // Default fallback for SSR
21
+ });
22
+
23
+ const updateFontSize = useCallback(() => {
24
+ setFontSize(getDocumentElementFontSize());
25
+ }, []);
26
+
27
+ useEffect(() => {
28
+ if (typeof document === 'undefined') {
29
+ return;
30
+ }
31
+
32
+ // Create a ResizeObserver to watch for font size changes on the document element
33
+ const resizeObserver = new ResizeObserver(updateFontSize);
34
+ resizeObserver.observe(document.documentElement);
35
+
36
+ // Also listen for viewport changes that might affect font size
37
+ const mediaQueryList = window.matchMedia('all');
38
+ const handleMediaChange = () => {
39
+ updateFontSize();
40
+ };
41
+
42
+ if (mediaQueryList.addEventListener) {
43
+ mediaQueryList.addEventListener('change', handleMediaChange);
44
+ } else {
45
+ // Fallback for older browsers
46
+ mediaQueryList.addListener(handleMediaChange);
47
+ }
48
+
49
+ return () => {
50
+ resizeObserver.disconnect();
51
+ if (mediaQueryList.removeEventListener) {
52
+ mediaQueryList.removeEventListener('change', handleMediaChange);
53
+ } else {
54
+ // Fallback for older browsers
55
+ mediaQueryList.removeListener(handleMediaChange);
56
+ }
57
+ };
58
+ }, []);
59
+
60
+ return useMemo(() => rem * fontSize, [fontSize]);
61
+ };