@estiva-app/ui 0.50.4 → 0.51.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.
@@ -0,0 +1,83 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as NavTreeStories from './NavTree.stories'
3
+
4
+ <Meta of={NavTreeStories} />
5
+
6
+ # NavTree
7
+
8
+ A sidebar's tree from one nested list: a quiet label, groups that fold
9
+ under the standard heading with their rows lightly indented, and rows under
10
+ rows to any depth.
11
+
12
+ <Canvas of={NavTreeStories.Default} />
13
+
14
+ ## When
15
+
16
+ - Places that nest, in a **Sidebar**: groups, rows in them, rows under
17
+ those rows. Hand it the whole list; it draws every level.
18
+ - `selected` is the current row: it is `active`, and only its group starts
19
+ open. Rows with rows under them start open.
20
+
21
+ <Canvas of={NavTreeStories.CurrentDeep} />
22
+
23
+ - A row's `hint` (a neutral **Chip**), `count` and `menu` are **NavItem**'s,
24
+ passed through.
25
+ - `groups={null}` while the first read is on its way draws a skeleton;
26
+ `[]` draws `emptyMessage`, level with the rows.
27
+
28
+ <Canvas of={NavTreeStories.Loading} />
29
+
30
+ ## When not
31
+
32
+ - A flat list of places → **NavItem** rows in the **Sidebar**.
33
+ - One group that folds, written by hand → **CollapsibleSection**.
34
+ - A list beside the thing that is open → **ListColumn**.
35
+
36
+ ## How
37
+
38
+ ```tsx
39
+ import { NavTree, Sidebar } from '@estiva-app/ui'
40
+
41
+ <Sidebar>
42
+ <NavTree
43
+ title="Groups"
44
+ groups={groups}
45
+ selected={currentId}
46
+ onSelect={(node, event) => {
47
+ event.preventDefault()
48
+ navigate(node.href)
49
+ }}
50
+ emptyMessage="Nothing here yet."
51
+ storageKey="app.sidebar.groups"
52
+ />
53
+ </Sidebar>
54
+ ```
55
+
56
+ - Each row is a **NavItem**, so a row is a link: a router app prevents the
57
+ default in `onSelect` and navigates.
58
+ - `storageKey` remembers each group open or closed in this browser, under
59
+ the key and the group's id. The rows under a row are not remembered yet.
60
+ - The words in `emptyMessage` are the caller's.
61
+
62
+ ## What it owns
63
+
64
+ It does these by itself. Write none of them around it.
65
+
66
+ | It does | So you never write | Refused when written by hand |
67
+ |---|---|---|
68
+ | Draws every level from one list, rows under rows | a recursive render of **NavItem**s and **CollapsibleSection**s | |
69
+ | Opens the group that holds the current row | a search through the list for the current row | |
70
+ | Says it is loading, or empty, in the rows' place | a skeleton or an **EmptyState** placed by hand | |
71
+
72
+ ## Keys
73
+
74
+ | Key | Does |
75
+ |---|---|
76
+ | Tab | each group's title, then its rows: each row's link, then its arrow |
77
+ | Enter, Space | on a group's title or a row's arrow: folds or unfolds |
78
+
79
+ It is not an ARIA tree yet: there are no arrow keys between rows.
80
+
81
+ ## Props
82
+
83
+ <Controls of={NavTreeStories.Default} />
@@ -0,0 +1,84 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { useState } from 'react'
4
+ import { Chip } from './Chip'
5
+ import { NavItem } from './NavItem'
6
+ import { NavTree, type NavTreeGroup } from './NavTree'
7
+ import { Sidebar } from './Sidebar'
8
+
9
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
10
+ const hint = <Chip type="neutral" label="Label" />
11
+
12
+ /** Three groups; the open one holds rows under rows, hints and counts, and long labels to see what fits. */
13
+ const GROUPS: NavTreeGroup[] = [
14
+ { id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item two', href: '#', icon: placeholder }] },
15
+ {
16
+ id: 'g2',
17
+ title: 'A group with a longer name',
18
+ nodes: [
19
+ { id: 'b', label: 'Item three', href: '#', icon: placeholder, count: 1, countLabel: '1 open' },
20
+ {
21
+ id: 'c',
22
+ label: 'An item whose label runs longer than the column',
23
+ href: '#',
24
+ icon: placeholder,
25
+ children: [
26
+ { id: 'c1', label: 'An item one level in, also long', href: '#', icon: placeholder, count: 2, countLabel: '2 open' },
27
+ { id: 'c2', label: 'Item four', href: '#', icon: placeholder, children: [{ id: 'c21', label: 'An item two levels in, long too', href: '#', icon: placeholder }] },
28
+ ],
29
+ },
30
+ { id: 'd', label: 'An item with a hint and a long label', href: '#', icon: placeholder, hint, count: 3, countLabel: '3 open' },
31
+ { id: 'e', label: 'Item five', href: '#', icon: placeholder, hint },
32
+ { id: 'f', label: 'An item with a hint and a count', href: '#', icon: placeholder, hint, count: 12, countLabel: '12 open' },
33
+ ],
34
+ },
35
+ { id: 'g3', title: 'Group three', nodes: [{ id: 'g', label: 'Item six', href: '#', icon: placeholder }] },
36
+ ]
37
+
38
+ const meta = {
39
+ title: 'Frame/NavTree',
40
+ component: NavTree,
41
+ parameters: { layout: 'fullscreen' },
42
+ args: { title: 'Groups', groups: GROUPS, selected: 'b', emptyMessage: 'Nothing here yet.' },
43
+ argTypes: { groups: { control: false } },
44
+ // In the Sidebar, under a first row, as it sits in an app; a click marks the row it was on.
45
+ render: function Render(args) {
46
+ const [selected, setSelected] = useState(args.selected)
47
+ return (
48
+ <div className="flex h-screen bg-bg-base">
49
+ <Sidebar>
50
+ <NavItem href="#" label="Item one" icon={placeholder} />
51
+ <NavTree
52
+ {...args}
53
+ selected={selected}
54
+ onSelect={(node, event) => {
55
+ event.preventDefault()
56
+ setSelected(node.id)
57
+ }}
58
+ />
59
+ </Sidebar>
60
+ </div>
61
+ )
62
+ },
63
+ } satisfies Meta<typeof NavTree>
64
+
65
+ export default meta
66
+ type Story = StoryObj<typeof meta>
67
+
68
+ /** Only the group holding the current row starts open. Point at a row with rows under it: its icon becomes the arrow. Point at a row with a hint: the chip shows. */
69
+ export const Default: Story = {}
70
+
71
+ /** The current row is two levels in: its group opens, and the rows above it are open too. */
72
+ export const CurrentDeep: Story = { args: { selected: 'c21' } }
73
+
74
+ /** No quiet label over the groups. */
75
+ export const NoTitle: Story = { args: { title: undefined } }
76
+
77
+ /** The first read is on its way. */
78
+ export const Loading: Story = { args: { groups: null } }
79
+
80
+ /** No groups: the caller's words, level with the rows. */
81
+ export const Empty: Story = { args: { groups: [] } }
82
+
83
+ /** Close a group and reload the page: still closed. This browser remembers each group, under the key. */
84
+ export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.nav-tree' } }
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the page claims: every level is drawn from the one list; only the
4
+ * group holding the current row starts open, and the current row is active;
5
+ * a click reaches onSelect with its node; loading and empty say so.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { NavTree, type NavTreeGroup } from './NavTree'
11
+
12
+ afterEach(cleanup)
13
+
14
+ const GROUPS: NavTreeGroup[] = [
15
+ { id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item a', href: '#a' }] },
16
+ {
17
+ id: 'g2',
18
+ title: 'Group two',
19
+ nodes: [{ id: 'b', label: 'Item b', href: '#b', children: [{ id: 'c', label: 'Item c', href: '#c', children: [{ id: 'd', label: 'Item d', href: '#d' }] }] }],
20
+ },
21
+ ]
22
+
23
+ const hidden = (text: string) => screen.getByText(text).closest('[hidden]') !== null
24
+
25
+ describe('NavTree', () => {
26
+ it('draws every level, and opens only the group holding the current row', () => {
27
+ render(<NavTree groups={GROUPS} selected="d" emptyMessage="Nothing here yet." />)
28
+ expect(hidden('Item a')).toBe(true)
29
+ expect(hidden('Item d')).toBe(false)
30
+ expect(screen.getByRole('link', { name: 'Item d' }).getAttribute('aria-current')).toBe('page')
31
+ })
32
+
33
+ it('a click reaches onSelect with the node it was on', async () => {
34
+ const onSelect = vi.fn((_node, event: { preventDefault: () => void }) => event.preventDefault())
35
+ render(<NavTree groups={GROUPS} selected="b" onSelect={onSelect} emptyMessage="Nothing here yet." />)
36
+ await userEvent.click(screen.getByRole('link', { name: 'Item c' }))
37
+ expect(onSelect.mock.calls[0][0].id).toBe('c')
38
+ })
39
+
40
+ it('draws the quiet label when given one', () => {
41
+ render(<NavTree title="Groups" groups={GROUPS} emptyMessage="Nothing here yet." />)
42
+ expect(screen.getByText('Groups')).not.toBeNull()
43
+ })
44
+
45
+ it('says it is empty in the caller’s words', () => {
46
+ render(<NavTree groups={[]} emptyMessage="Nothing here yet." />)
47
+ expect(screen.getByText('Nothing here yet.')).not.toBeNull()
48
+ })
49
+
50
+ it('draws no rows while loading', () => {
51
+ render(<NavTree groups={null} emptyMessage="Nothing here yet." />)
52
+ expect(screen.queryAllByRole('link')).toHaveLength(0)
53
+ })
54
+ })
@@ -0,0 +1,112 @@
1
+ import type { MouseEvent, ReactNode } from 'react'
2
+ import { CollapsibleSection } from './CollapsibleSection'
3
+ import { EmptyState } from './EmptyState'
4
+ import { NavItem } from './NavItem'
5
+ import { SectionHeader } from './SectionHeader'
6
+ import { SkeletonList } from './Skeleton'
7
+
8
+ /** One row of the tree, and the rows under it. */
9
+ export interface NavTreeNode {
10
+ id: string
11
+ label: string
12
+ href: string
13
+ /** 16px, stroke 1.5. */
14
+ icon?: ReactNode
15
+ /** `0` or absent renders no counter. */
16
+ count?: number
17
+ /** What the count counts, for the tooltip. */
18
+ countLabel?: string
19
+ /** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
20
+ hint?: ReactNode
21
+ /** The row's "More options" menu: `MenuItem`s. */
22
+ menu?: ReactNode
23
+ /** Rows under this one. Their row folds them; open unless `defaultOpen` says otherwise. */
24
+ children?: NavTreeNode[]
25
+ defaultOpen?: boolean
26
+ }
27
+
28
+ /** A group heading over its rows: the top of the tree. */
29
+ export interface NavTreeGroup {
30
+ id: string
31
+ title: string
32
+ nodes: NavTreeNode[]
33
+ }
34
+
35
+ export interface NavTreeProps {
36
+ /** The quiet label over the groups. Absent, none is drawn. */
37
+ title?: string
38
+ /** `null` while the first read is on its way: a skeleton in the rows' place. */
39
+ groups: NavTreeGroup[] | null
40
+ /** The row that is current: `active`, and its group starts open. */
41
+ selected?: string
42
+ /** A row was clicked. A router app prevents the default and navigates. */
43
+ onSelect?: (node: NavTreeNode, event: MouseEvent<HTMLAnchorElement>) => void
44
+ /** What to say when there are no groups. The words are the caller's. */
45
+ emptyMessage: string
46
+ /** Remember each group open or closed in this browser, under this key and the group's id. The app prefixes it. */
47
+ storageKey?: string
48
+ }
49
+
50
+ function holds(nodes: NavTreeNode[], id: string | undefined): boolean {
51
+ return id !== undefined && nodes.some((node) => node.id === id || holds(node.children ?? [], id))
52
+ }
53
+
54
+ function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick<NavTreeProps, 'selected' | 'onSelect'>) {
55
+ return (
56
+ <NavItem
57
+ href={node.href}
58
+ label={node.label}
59
+ icon={node.icon}
60
+ count={node.count}
61
+ countLabel={node.countLabel}
62
+ hint={node.hint}
63
+ menu={node.menu}
64
+ active={node.id === selected}
65
+ defaultOpen={node.defaultOpen ?? true}
66
+ onClick={onSelect ? (event) => onSelect(node, event) : undefined}
67
+ >
68
+ {node.children?.length
69
+ ? node.children.map((child) => <Row key={child.id} node={child} selected={selected} onSelect={onSelect} />)
70
+ : undefined}
71
+ </NavItem>
72
+ )
73
+ }
74
+
75
+ /**
76
+ * A sidebar's tree, from one nested list (Katerina, 2 October: Leaf's folder
77
+ * tree): a quiet label, then each group as the standard heading folding its
78
+ * rows, lightly indented under its title; rows under rows to any depth, each
79
+ * row a link whose icon becomes the arrow that folds what is under it. Only
80
+ * the group that holds the current row starts open.
81
+ */
82
+ export function NavTree({ title, groups, selected, onSelect, emptyMessage, storageKey }: NavTreeProps) {
83
+ return (
84
+ <>
85
+ {title && <SectionHeader title={title} look="quiet" className="mt-4 shrink-0" />}
86
+ {groups === null ? (
87
+ <SkeletonList rows={5} />
88
+ ) : groups.length === 0 ? (
89
+ // Level with the rows: a row pads itself, so the line takes a row's inset (Katerina, 14 September).
90
+ <div className="px-2 py-1">
91
+ <EmptyState scope="section" message={emptyMessage} />
92
+ </div>
93
+ ) : (
94
+ groups.map((group) => (
95
+ <CollapsibleSection
96
+ key={group.id}
97
+ title={group.title}
98
+ indent
99
+ defaultOpen={holds(group.nodes, selected)}
100
+ storageKey={storageKey ? `${storageKey}.${group.id}` : undefined}
101
+ className="mt-2 shrink-0"
102
+ contentClassName="gap-px"
103
+ >
104
+ {group.nodes.map((node) => (
105
+ <Row key={node.id} node={node} selected={selected} onSelect={onSelect} />
106
+ ))}
107
+ </CollapsibleSection>
108
+ ))
109
+ )}
110
+ </>
111
+ )
112
+ }
@@ -106,7 +106,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
106
106
  // A row's words: NavItem's size and colour, brightening under the pointer with the row.
107
107
  <span className="min-w-0 flex-1 truncate text-body-2">{title}</span>
108
108
  ) : (
109
- <SectionLabel>{title}</SectionLabel>
109
+ <SectionLabel folds={chevron}>{title}</SectionLabel>
110
110
  )}
111
111
  </>
112
112
  ),
@@ -40,7 +40,10 @@ It is a bare span: the caller owns the row it sits in and the spacing
40
40
  around it. Read it `tone="secondary"` where it labels rather than leads — menu
41
41
  headings and the command palette's groups do. `tone="muted"` where it marks a
42
42
  place in a list rather than heading it — a date between messages. `truncate`
43
- cuts a long label short with an ellipsis where its row has no room. A colour
43
+ cuts a long label short with an ellipsis where its row has no room. `folds`
44
+ is the title of a group that opens and closes: **SectionHeader** passes it
45
+ whenever it draws the chevron, so in Leaf a folding group keeps Ship's type
46
+ while every other label takes Signal's. A colour
44
47
  passed in `className` is refused by the lint.
45
48
 
46
49
  - Import it — never copy its classes. A copied class list drifts the first time
@@ -45,3 +45,6 @@ export const Truncated: Story = {
45
45
  </div>
46
46
  ),
47
47
  }
48
+
49
+ /** The title of a group that folds: in Leaf it keeps Ship's type; in Signal and Ship it looks like any label. Switch the toolbar to Leaf to see the difference. */
50
+ export const Folds: Story = { args: { folds: true, children: 'Group' } }
@@ -18,6 +18,10 @@ import { cn } from './cn'
18
18
  * long label short with an ellipsis where its row has no room, as a date line in
19
19
  * a narrow panel must.
20
20
  *
21
+ * `folds` is the title of a group that opens and closes: in Leaf it keeps
22
+ * Ship's type rather than taking Signal's micro-label, so a folder reads as
23
+ * a name (Katerina, 2 October). Every other Leaf label keeps Signal's.
24
+ *
21
25
  * Deliberately NOT the same thing as Property's stacked field label (the
22
26
  * 9px `menu`-token one): they were merged for a day and unmerged by
23
27
  * Katerina's ruling (2026-09-01) — a section title and a field label are
@@ -27,20 +31,24 @@ export function SectionLabel({
27
31
  children,
28
32
  tone = 'primary',
29
33
  truncate = false,
34
+ folds = false,
30
35
  className,
31
36
  }: {
32
37
  children: ReactNode
33
38
  tone?: 'primary' | 'secondary' | 'muted'
34
39
  /** Cut a long label short with an ellipsis instead of letting it wrap or spill. */
35
40
  truncate?: boolean
41
+ /** The title of a group that opens and closes. In Leaf, Ship's type instead of the micro-label. */
42
+ folds?: boolean
36
43
  className?: string
37
44
  }) {
38
45
  return (
39
46
  <span
40
47
  className={cn(
41
48
  'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
42
- // Leaf's section labels are Signal's type (Katerina, 28 September).
43
- 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
49
+ // Leaf's section labels are Signal's type (Katerina, 28 September),
50
+ // but a group that folds keeps Ship's (Katerina, 2 October).
51
+ !folds && 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
44
52
  tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
45
53
  truncate && 'min-w-0 truncate',
46
54
  className,
@@ -8,7 +8,7 @@ import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } f
8
8
  * the browser shows it as its tooltip. `no-native-title.test.ts` holds this
9
9
  * list to the catalogue: a part that gains a `title` of its own joins it.
10
10
  */
11
- export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'Panel', 'SectionHeader'] as const
11
+ export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'NavTree', 'Panel', 'SectionHeader'] as const
12
12
 
13
13
  const child = (node: Node, key: string) => node[key] as Node | null | undefined
14
14
  const nameOf = (node: Node | null | undefined): string | undefined =>
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
70
70
  export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
71
71
  export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
72
72
  export { NavItem, type NavItemProps } from './NavItem'
73
+ export { NavTree, type NavTreeGroup, type NavTreeNode, type NavTreeProps } from './NavTree'
73
74
  export { Popover, type PopoverProps } from './Popover'
74
75
  export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
75
76
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
package/src/looks.ts CHANGED
@@ -46,3 +46,11 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
46
46
  /** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
47
47
  export const SIDEBAR_ROW_CLASSES = 'rounded-md transition-colors'
48
48
  export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
49
+
50
+ /**
51
+ * A panel that slides open and shut on Base UI's Collapsible: Base UI measures
52
+ * it and writes its height to a variable, `auto` again once the slide ends, and
53
+ * the panel is 0 high on its opening and closing frames. None under
54
+ * prefers-reduced-motion. CollapsibleSection's rows, and the rows under a NavItem.
55
+ */
56
+ export const COLLAPSIBLE_PANEL_CLASSES = 'h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none data-[starting-style]:h-0 data-[ending-style]:h-0'
@@ -48,18 +48,18 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 104 names over 64 files: 90 components, 13 helpers and 1 hook (the block nodes joined on 30 September, MAN-9)', () => {
51
+ it('counts 105 names over 65 files: 91 components, 13 helpers and 1 hook (NavTree joined on 2 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The five `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's and BlockNodes' pages.
55
55
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
56
- expect(kinds).toEqual({ component: 90, helper: 13, hook: 1 })
57
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(64)
56
+ expect(kinds).toEqual({ component: 91, helper: 13, hook: 1 })
57
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(65)
58
58
  })
59
59
 
60
60
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
61
61
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
62
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(61)
62
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(62)
63
63
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(43)
64
64
  })
65
65
 
@@ -116,6 +116,7 @@ the **floating** TopBar goes with the **Rail** and the content card.
116
116
  | The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
117
117
  | The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
118
118
  | A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
119
+ | Groups of rows in that column, rows under rows, from one nested list | **NavTree**, inside the **Sidebar** |
119
120
  | A 64px icon strip | **Rail**, filled with **RailItem** tiles |
120
121
  | The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
121
122
  | The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |