@duro-app/ui 5.17.0 → 5.19.0

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 (72) hide show
  1. package/dist/Table-DnaoH-h5.js.map +1 -1
  2. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/components/AppShell/AppShell.stories.d.ts +5 -0
  4. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  5. package/dist/components/Menu/native.css.d.ts +1 -0
  6. package/dist/components/Menu/native.css.d.ts.map +1 -1
  7. package/dist/components/Popover/native.css.d.ts +1 -0
  8. package/dist/components/Popover/native.css.d.ts.map +1 -1
  9. package/dist/components/SideNav/SideNav.d.ts +2 -1
  10. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  11. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  12. package/dist/components/TextLink/native.css.d.ts +19 -0
  13. package/dist/components/TextLink/native.css.d.ts.map +1 -0
  14. package/dist/components/Tree/Tree.d.ts +12 -22
  15. package/dist/components/Tree/Tree.d.ts.map +1 -1
  16. package/dist/components/Tree/Tree.stories.d.ts +33 -0
  17. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  18. package/dist/components/Tree/TreeContext.d.ts +5 -5
  19. package/dist/components/Tree/TreeContext.d.ts.map +1 -1
  20. package/dist/components/Tree/TreeFocusStore.d.ts +25 -0
  21. package/dist/components/Tree/TreeFocusStore.d.ts.map +1 -0
  22. package/dist/components/Tree/TreeItemRow.d.ts +25 -0
  23. package/dist/components/Tree/TreeItemRow.d.ts.map +1 -0
  24. package/dist/components/Tree/TreeRow.d.ts +3 -0
  25. package/dist/components/Tree/TreeRow.d.ts.map +1 -0
  26. package/dist/components/Tree/TreeRowProps.d.ts +41 -0
  27. package/dist/components/Tree/TreeRowProps.d.ts.map +1 -0
  28. package/dist/components/Tree/TreeRows.d.ts +70 -0
  29. package/dist/components/Tree/TreeRows.d.ts.map +1 -0
  30. package/dist/components/Tree/TreeWindow.d.ts +21 -0
  31. package/dist/components/Tree/TreeWindow.d.ts.map +1 -0
  32. package/dist/components/Tree/styles.css.d.ts +69 -1
  33. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  34. package/dist/components/Tree/useTreeKeyboard.d.ts +15 -9
  35. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -1
  36. package/dist/index.css +1 -1
  37. package/dist/index.js +3798 -3477
  38. package/dist/index.js.map +1 -1
  39. package/dist/shared/InLandmarkContext.d.ts +7 -0
  40. package/dist/shared/InLandmarkContext.d.ts.map +1 -0
  41. package/dist/shared/navigate.d.ts.map +1 -1
  42. package/package.json +3 -3
  43. package/src/components/AppShell/AppShell.native.tsx +17 -14
  44. package/src/components/AppShell/AppShell.stories.tsx +30 -0
  45. package/src/components/AppShell/AppShell.tsx +3 -2
  46. package/src/components/Menu/Menu.native.tsx +4 -24
  47. package/src/components/Menu/native.css.ts +4 -0
  48. package/src/components/Panel/Panel.meta.ts +1 -1
  49. package/src/components/Popover/native.css.ts +4 -0
  50. package/src/components/SideNav/SideNav.meta.ts +1 -0
  51. package/src/components/SideNav/SideNav.tsx +18 -4
  52. package/src/components/TextLink/TextLink.meta.ts +1 -1
  53. package/src/components/TextLink/TextLink.native.tsx +55 -0
  54. package/src/components/TextLink/native.css.ts +36 -0
  55. package/src/components/Tree/Tree.meta.ts +1 -1
  56. package/src/components/Tree/Tree.stories.tsx +376 -1
  57. package/src/components/Tree/Tree.tsx +88 -131
  58. package/src/components/Tree/TreeContext.ts +5 -5
  59. package/src/components/Tree/TreeFocusStore.ts +73 -0
  60. package/src/components/Tree/TreeItemRow.tsx +71 -0
  61. package/src/components/Tree/TreeRow.native.tsx +96 -0
  62. package/src/components/Tree/TreeRow.tsx +112 -0
  63. package/src/components/Tree/TreeRowProps.ts +42 -0
  64. package/src/components/Tree/TreeRows.ts +139 -0
  65. package/src/components/Tree/TreeWindow.native.tsx +19 -0
  66. package/src/components/Tree/TreeWindow.tsx +118 -0
  67. package/src/components/Tree/styles.css.ts +81 -2
  68. package/src/components/Tree/useTreeKeyboard.ts +86 -42
  69. package/src/index.native.ts +4 -0
  70. package/src/shared/InLandmarkContext.ts +8 -0
  71. package/src/shared/followLink.native.ts +33 -0
  72. package/src/shared/navigate.ts +2 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * True inside `AppShell.Rail`, which is already a named navigation landmark.
3
+ * A `SideNav.Root` reads it and renders no second navigation landmark: a plain
4
+ * block, or `role="group"` carrying its `aria-label` when it has one.
5
+ */
6
+ export declare const InLandmarkContext: import('react').Context<boolean>;
7
+ //# sourceMappingURL=InLandmarkContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InLandmarkContext.d.ts","sourceRoot":"","sources":["../../src/shared/InLandmarkContext.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,kCAAuB,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"navigate.d.ts","sourceRoot":"","sources":["../../src/shared/navigate.ts"],"names":[],"mappings":"AAgBA,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;IAClC,cAAc,IAAI,IAAI,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;AAEtE,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAS3D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GAAG,SAAS,EAClC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GACrB,CAAC,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC,GAAG,SAAS,CAM/C"}
1
+ {"version":3,"file":"navigate.d.ts","sourceRoot":"","sources":["../../src/shared/navigate.ts"],"names":[],"mappings":"AAkBA,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;IAClC,cAAc,IAAI,IAAI,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,KAAK,IAAI,CAAA;AAEtE,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAS3D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,UAAU,GAAG,SAAS,EAClC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,GACrB,CAAC,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC,GAAG,SAAS,CAM/C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.17.0",
3
+ "version": "5.19.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -90,9 +90,9 @@
90
90
  }
91
91
  },
92
92
  "dependencies": {
93
- "@tanstack/react-virtual": "^3.14.6",
93
+ "@tanstack/react-virtual": "^3.14.13",
94
94
  "qrcode": "^1.5.4",
95
- "@duro-app/tokens": "^5.17.0"
95
+ "@duro-app/tokens": "^5.19.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@babel/preset-typescript": "^7.28.0",
@@ -14,6 +14,7 @@ import {Button} from '../Button/Button'
14
14
  import {Drawer} from '../Drawer/Drawer'
15
15
  import {Icon} from '../Icon'
16
16
  import {useSafeAreaInsets} from '../../shared/safeArea'
17
+ import {InLandmarkContext} from '../../shared/InLandmarkContext'
17
18
  import {nativeStyles} from './native.css'
18
19
 
19
20
  export type AppShellCollapse = 'sm' | 'md' | 'lg'
@@ -117,20 +118,22 @@ function Rail({'aria-label': ariaLabel, children, footer}: RailProps) {
117
118
  const foot = typeof footer === 'function' ? footer({close}) : footer
118
119
 
119
120
  return (
120
- <Drawer.Root anchor="left" open={open} onOpenChange={setOpen}>
121
- <Drawer.Portal size="sm">
122
- <Drawer.Header>
123
- <Drawer.Title>{ariaLabel}</Drawer.Title>
124
- <Drawer.Close aria-label={closeLabel} />
125
- </Drawer.Header>
126
- <Drawer.Body>
127
- <html.nav aria-label={ariaLabel}>
128
- {content}
129
- {foot != null && <html.div style={nativeStyles.drawerFooter}>{foot}</html.div>}
130
- </html.nav>
131
- </Drawer.Body>
132
- </Drawer.Portal>
133
- </Drawer.Root>
121
+ <InLandmarkContext.Provider value={true}>
122
+ <Drawer.Root anchor="left" open={open} onOpenChange={setOpen}>
123
+ <Drawer.Portal size="sm">
124
+ <Drawer.Header>
125
+ <Drawer.Title>{ariaLabel}</Drawer.Title>
126
+ <Drawer.Close aria-label={closeLabel} />
127
+ </Drawer.Header>
128
+ <Drawer.Body>
129
+ <html.nav aria-label={ariaLabel}>
130
+ {content}
131
+ {foot != null && <html.div style={nativeStyles.drawerFooter}>{foot}</html.div>}
132
+ </html.nav>
133
+ </Drawer.Body>
134
+ </Drawer.Portal>
135
+ </Drawer.Root>
136
+ </InLandmarkContext.Provider>
134
137
  )
135
138
  }
136
139
 
@@ -348,12 +348,41 @@ export const DesktopLandmarks: Story = {
348
348
  await expect(canvas.getAllByRole('banner')).toHaveLength(1)
349
349
  const names = canvas.getAllByRole('navigation').map((nav) => nav.getAttribute('aria-label'))
350
350
  await expect(new Set(names).size).toBe(names.length)
351
+ // the SideNav inside the rail is a group, not a second navigation landmark
352
+ await expect(canvas.getAllByRole('navigation')).toHaveLength(1)
351
353
  await expect(canvas.queryByRole('button', {name: args.menuLabel})).toBeNull()
352
354
  // the brand is the rail's copy
353
355
  await expect(rail.contains(canvas.getByRole('link', {name: args.brand}))).toBe(true)
354
356
  },
355
357
  }
356
358
 
359
+ /**
360
+ * A SideNav without an `aria-label` inside a labelled Rail: the Rail is the one
361
+ * navigation landmark, the SideNav adds none.
362
+ */
363
+ export const RailWithUnlabelledSideNav: Story = {
364
+ tags: TEST_ONLY,
365
+ render: ({width}) => (
366
+ <Frame px={FRAME_PX[width]}>
367
+ <AppShell.Root menuLabel="Menu" closeLabel="Close navigation" skipLabel="Skip to content">
368
+ <AppShell.Rail aria-label="Main">
369
+ <SideNav.Root defaultValue="/">
370
+ <SideNav.Item value="/">Dashboard</SideNav.Item>
371
+ <SideNav.Item value="/articles">Articles</SideNav.Item>
372
+ </SideNav.Root>
373
+ </AppShell.Rail>
374
+ <AppShell.Main>
375
+ <Heading level={1}>Docs</Heading>
376
+ </AppShell.Main>
377
+ </AppShell.Root>
378
+ </Frame>
379
+ ),
380
+ play: async ({canvas}) => {
381
+ await expect(canvas.getAllByRole('navigation')).toHaveLength(1)
382
+ await expect(canvas.getByRole('navigation', {name: 'Main'})).toBeVisible()
383
+ },
384
+ }
385
+
357
386
  /**
358
387
  * The Menu button opens the rail in a Drawer. Focus moves into the drawer,
359
388
  * and back to the Menu button when it closes — by Escape, and after a pick.
@@ -368,6 +397,7 @@ export const PhoneDrawer: Story = {
368
397
  await expect(menu).toHaveAttribute('aria-expanded', 'true')
369
398
  const drawerNav = within(dialog).getByRole('navigation', {name: 'Navigation'})
370
399
  await waitFor(() => expect(drawerNav.contains(document.activeElement)).toBe(true))
400
+ await expect(within(dialog).getAllByRole('navigation')).toHaveLength(1)
371
401
  // the rail's content is rendered once, in the drawer while it is open:
372
402
  // counted in the DOM (a hidden copy would still duplicate a Tree's ids)
373
403
  await expect(page().getAllByText('Articles')).toHaveLength(1)
@@ -20,6 +20,7 @@ import {Icon} from '../Icon'
20
20
  import {TextLink} from '../TextLink/TextLink'
21
21
  import {useContainerBelow} from '../../hooks/useContainerBelow'
22
22
  import type {OnNavigate} from '../../shared/navigate'
23
+ import {InLandmarkContext} from '../../shared/InLandmarkContext'
23
24
  import {styles} from './styles.css'
24
25
 
25
26
  /* AppShell — an application's frame: a navigation rail beside the main
@@ -253,7 +254,7 @@ function Rail({'aria-label': ariaLabel, children, footer}: RailProps) {
253
254
  }, [open])
254
255
 
255
256
  return (
256
- <>
257
+ <InLandmarkContext.Provider value={true}>
257
258
  {/* Rendered in one place at a time, so ids (a Tree's) and the landmark
258
259
  name are never duplicated: the drawer only opens below the collapse
259
260
  point, where the rail is hidden. The brand stays: the bar holds the
@@ -281,7 +282,7 @@ function Rail({'aria-label': ariaLabel, children, footer}: RailProps) {
281
282
  </Drawer.Body>
282
283
  </Drawer.Portal>
283
284
  </Drawer.Root>
284
- </>
285
+ </InLandmarkContext.Provider>
285
286
  )
286
287
  }
287
288
 
@@ -9,7 +9,7 @@ import {
9
9
  useId,
10
10
  useRef,
11
11
  } from 'react'
12
- import {Linking, Platform, Pressable, ScrollView, StyleSheet} from 'react-native'
12
+ import {Platform, Pressable, ScrollView, StyleSheet} from 'react-native'
13
13
  import {html} from 'react-strict-dom'
14
14
  import {SIZES_PX} from '@duro-app/tokens/keys'
15
15
  import {styles} from './styles.css'
@@ -18,7 +18,8 @@ import {useMenuOpen} from './useMenuOpen'
18
18
  import {AnchoredOverlay} from '../../shared/portal/AnchoredOverlay'
19
19
  import {focusForAccessibility} from '../../shared/accessibilityFocus'
20
20
  import {mergeRefs} from '../../shared/mergeRefs'
21
- import type {LinkClickEvent, OnNavigate} from '../../shared/navigate'
21
+ import {followLink} from '../../shared/followLink.native'
22
+ import type {OnNavigate} from '../../shared/navigate'
22
23
  import type {ControlSize} from '../../shared/types'
23
24
  import {useTooltipHint} from '../Tooltip/TooltipHint'
24
25
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
@@ -223,28 +224,7 @@ interface LinkItemProps {
223
224
 
224
225
  function LinkItem({href, onNavigate, children}: LinkItemProps) {
225
226
  const {closeThen} = useMenu()
226
- const follow = () => {
227
- let prevented = false
228
- const event: LinkClickEvent = {
229
- button: 0,
230
- metaKey: false,
231
- ctrlKey: false,
232
- shiftKey: false,
233
- altKey: false,
234
- get defaultPrevented() {
235
- return prevented
236
- },
237
- preventDefault() {
238
- prevented = true
239
- },
240
- }
241
- onNavigate?.(href, event)
242
- if (!prevented) {
243
- Linking.openURL(href).catch((error: unknown) => {
244
- console.warn(`[duro] Menu.LinkItem: could not open ${href}: ${String(error)}`)
245
- })
246
- }
247
- }
227
+ const follow = () => followLink(href, onNavigate, 'Menu.LinkItem')
248
228
  return (
249
229
  <Pressable accessibilityRole="link" onPress={() => closeThen(follow)}>
250
230
  {({pressed}) => (
@@ -8,8 +8,12 @@ export const nativeStyles = css.create({
8
8
  nativeFlex: {
9
9
  display: 'flex',
10
10
  },
11
+ // The shared popup's `zIndex: layers.popup` is the CSS string '1050', which Android's
12
+ // view manager refuses (iOS ignores it). The popup sits alone in an OverlayModal
13
+ // window, where web layering has no job: a number is all it needs.
11
14
  popupInPlace: {
12
15
  position: 'relative',
16
+ zIndex: 0,
13
17
  },
14
18
  // A press shows the highlight that hover and the arrow keys show on the web.
15
19
  itemPressed: {
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Structural primitive for grouping content with header, body, and footer slots. A "workspace surface" for forms, settings sections, and inspectors. Sub-components are purely structural — no context required.',
5
+ 'Structural primitive for grouping content with header, body, and footer slots. A "workspace surface" for forms, settings sections, and inspectors. Sub-components are purely structural — no context required. On native the Body does not scroll (unlike the web Body): wrap long content in ScrollArea. A TextLink inside needs onNavigate for in-app routes on native.',
6
6
  whenToUse: [
7
7
  'Organizing content with distinct header/body/footer sections',
8
8
  'Settings panels, form containers, inspector views',
@@ -7,7 +7,11 @@ export const nativeStyles = css.create({
7
7
  nativeFlex: {
8
8
  display: 'flex',
9
9
  },
10
+ // The shared popup's `zIndex: layers.floating` is the CSS string '50', which Android's
11
+ // view manager refuses (iOS ignores it). The popup sits alone in an OverlayModal
12
+ // window, where web layering has no job: a number is all it needs.
10
13
  popupInPlace: {
11
14
  position: 'relative',
15
+ zIndex: 0,
12
16
  },
13
17
  })
@@ -4,6 +4,7 @@ export const meta: ComponentMeta = {
4
4
  description:
5
5
  'Vertical side navigation. `Section` is a labelled block that is always open — the default for primary navigation. `Group` is the same block behind a chevron (a disclosure) — reach for it only for a rare/advanced region, an unbounded data-driven list, or a rail long enough that flat stops reading as an overview; mixing the two in one rail is the healthy shape. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required. On React Native the same parts render, and a Group knows whether it holds the active item from its Items, not from the DOM.',
6
6
  whenToUse: [
7
+ 'Inside `AppShell.Rail`: the Rail is the navigation landmark, so Root renders a plain block, or `role="group"` with its `aria-label` when it has one. Name the Rail "Main", not "Navigation" (screen readers would announce "Navigation navigation"). Outside a Rail, Root is a `nav` landmark.',
7
8
  'App-level sidebar navigation — flat `Section`s, every destination visible',
8
9
  'Settings navigation with labelled regions',
9
10
  '`Group` for a rare or advanced region tucked below the flat sections',
@@ -13,6 +13,7 @@ import {styles} from './styles.css'
13
13
  import {useControllableValue} from '../../hooks/useControllableValue'
14
14
  import {SideNavContext, useSideNav} from './SideNavContext'
15
15
  import {isNative} from '../../platform'
16
+ import {InLandmarkContext} from '../../shared/InLandmarkContext'
16
17
  import {Chevron} from './Chevron'
17
18
 
18
19
  /** A Group's own items: each Item inside it registers its value here. */
@@ -51,7 +52,8 @@ interface RootProps {
51
52
  /**
52
53
  * Names the navigation landmark ("Main", "Settings"). Give one whenever
53
54
  * the page has another nav (a breadcrumb, a pager): screen readers list
54
- * landmarks by name.
55
+ * landmarks by name. Inside `AppShell.Rail` it is not a landmark (the Rail
56
+ * is): the name then labels a `role="group"`.
55
57
  */
56
58
  'aria-label'?: string
57
59
  value?: string
@@ -75,6 +77,7 @@ function Root({
75
77
  }
76
78
  : undefined,
77
79
  )
80
+ const inLandmark = useContext(InLandmarkContext)
78
81
  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
79
82
  const orderRef = useRef<string[]>([])
80
83
 
@@ -124,9 +127,20 @@ function Root({
124
127
  orderRef,
125
128
  }}
126
129
  >
127
- <html.nav role="navigation" aria-label={ariaLabel} style={styles.root}>
128
- {children}
129
- </html.nav>
130
+ {inLandmark ? (
131
+ // AppShell.Rail is already the navigation landmark: keep the name as a group.
132
+ <html.div
133
+ role={ariaLabel != null ? 'group' : undefined}
134
+ aria-label={ariaLabel}
135
+ style={styles.root}
136
+ >
137
+ {children}
138
+ </html.div>
139
+ ) : (
140
+ <html.nav role="navigation" aria-label={ariaLabel} style={styles.root}>
141
+ {children}
142
+ </html.nav>
143
+ )}
130
144
  </SideNavContext.Provider>
131
145
  )
132
146
  }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer. onNavigate(href, event) hands a plain primary click to a client router (the app calls event.preventDefault(), then navigates); modified clicks keep the browser default.',
5
+ 'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer. onNavigate(href, event) hands a plain primary click to a client router (the app calls event.preventDefault(), then navigates); modified clicks keep the browser default. On native the link renders no href and a press goes through onNavigate, then the system (Linking.openURL): onNavigate is required for in-app routes on native, and a target set skips it.',
6
6
  whenToUse: [
7
7
  'A link inside a sentence or paragraph',
8
8
  'A standalone text link under a list or card ("View all requests")',
@@ -0,0 +1,55 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {nativeStyles} from './native.css'
4
+ // Explicit suffix: a native-only module has no web file for moduleSuffixes to pair.
5
+ import {followLink} from '../../shared/followLink.native'
6
+ import type {OnNavigate} from '../../shared/navigate'
7
+
8
+ export type TextLinkVariant = 'default' | 'subtle'
9
+
10
+ interface TextLinkProps {
11
+ href: string
12
+ /** `_blank` hands the URL to the system; `onNavigate` is then not called. */
13
+ target?: '_blank' | '_self'
14
+ /** Ignored on native: there is no `rel`. */
15
+ rel?: string
16
+ variant?: TextLinkVariant
17
+ /** Accessible name, when the visible text alone does not say where it goes. */
18
+ 'aria-label'?: string
19
+ /**
20
+ * Client-side navigation: called for a press on a link with no `target`.
21
+ * Call `event.preventDefault()`, then your router's navigate. Required for
22
+ * an in-app route on native: without it, the URL opens with the system.
23
+ */
24
+ onNavigate?: OnNavigate
25
+ children: ReactNode
26
+ }
27
+
28
+ /**
29
+ * Native TextLink (ADR-0002): an inline `Text` with `role="link"` that renders
30
+ * NO `href`, and follows the link through `followLink` on press.
31
+ *
32
+ * holds-until: a react-strict-dom release forwards the press of an `html.a`
33
+ * that has an `href` (react/react-strict-dom#58). Until then RSD 0.0.55
34
+ * replaces that `onPress` with a dev-only "href handling is not implemented"
35
+ * stub (dist/native/index.js:3094-3100), so `href` stays off the element and
36
+ * scripts/check-native-graph.mjs refuses it. Then: pass `href`, drop the rule.
37
+ */
38
+ export function TextLink({
39
+ href,
40
+ target,
41
+ variant = 'default',
42
+ 'aria-label': ariaLabel,
43
+ onNavigate,
44
+ children,
45
+ }: TextLinkProps) {
46
+ return (
47
+ <html.a
48
+ aria-label={ariaLabel}
49
+ onClick={() => followLink(href, target ? undefined : onNavigate, 'TextLink')}
50
+ style={[nativeStyles.base, nativeStyles[variant]]}
51
+ >
52
+ {children}
53
+ </html.a>
54
+ )
55
+ }
@@ -0,0 +1,36 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+
4
+ // Native TextLink: the same colour and underline facts as ./styles.css.ts —
5
+ // change both together. A copy, not a composition: the web file's base style
6
+ // carries transition* and :focus-visible outline keys that react-strict-dom
7
+ // rejects on native, and its :hover state becomes :active (a press) here.
8
+ export const nativeStyles = css.create({
9
+ base: {
10
+ fontFamily: 'inherit',
11
+ fontSize: 'inherit',
12
+ lineHeight: 'inherit',
13
+ textDecorationLine: 'underline',
14
+ textDecorationStyle: 'solid',
15
+ },
16
+ default: {
17
+ color: {
18
+ default: colors.accent,
19
+ ':active': colors.accentHover,
20
+ },
21
+ textDecorationColor: {
22
+ default: colors.accent,
23
+ ':active': colors.accentHover,
24
+ },
25
+ },
26
+ subtle: {
27
+ color: {
28
+ default: 'inherit',
29
+ ':active': colors.accent,
30
+ },
31
+ textDecorationColor: {
32
+ default: colors.textMuted,
33
+ ':active': colors.accent,
34
+ },
35
+ },
36
+ })
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native selection, expansion and aria-level are the same; the keyboard model (arrows, Home/End, typeahead) is web only.',
5
+ 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. A click or Enter on a branch row with no href selects it and toggles it open or closed (a folder); a branch with href navigates, and its chevron toggles. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native each row is a pressable link (href) or button, and a branch has its own chevron control that expands it (named by the optional Root prop expandLabel(text), default the item text); selection, expansion and aria-level are the same, the keyboard model (arrows, Home/End, typeahead) is web only. On native onNavigate is required for in-app routes: without it a relative href goes to the system (Linking.openURL). On web a tree with more items than Root virtualizeThreshold (default 150, open or not) is windowed: only the rows in view mount, as flat treeitems with aria-level, aria-setsize and aria-posinset; maxHeight caps its scroll viewport. Items must be JSX descendants of Root (fragments and arrays are fine, an Item hidden inside another component is not seen by the window). Native renders every row.',
6
6
  whenToUse: [
7
7
  'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
8
8
  'Picking one node of a hierarchy to show its details beside the tree',