@remit/ui 0.0.66 → 0.0.68

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { FolderRow, type FolderRowProps } from "./folder-row.js";
6
+
7
+ const render = (props: Partial<FolderRowProps>) =>
8
+ renderToString(
9
+ createElement(FolderRow, {
10
+ label: "Travel",
11
+ depth: 0,
12
+ expanded: false,
13
+ ariaLabel: "Move to Travel",
14
+ ...props,
15
+ }),
16
+ );
17
+
18
+ describe("FolderRow", () => {
19
+ it("is a tree item that carries its nesting and its label", () => {
20
+ const html = render({ depth: 2 });
21
+ assert.match(html, /<button[^>]*role="treeitem"/);
22
+ assert.match(html, /aria-level="3"/);
23
+ assert.match(html, /aria-label="Move to Travel"/);
24
+ assert.match(html, /aria-expanded="false"/);
25
+ assert.match(html, />Travel</);
26
+ });
27
+
28
+ it("indents by its depth and leaves a root flush", () => {
29
+ assert.match(render({ depth: 2 }), /width:28px/);
30
+ assert.doesNotMatch(render({ depth: 0 }), /style="width/);
31
+ });
32
+
33
+ it("turns the chevron once its children are on screen", () => {
34
+ assert.doesNotMatch(render({}), /rotate-90/);
35
+ assert.match(render({ expanded: true }), /rotate-90/);
36
+ });
37
+
38
+ it("checks the row that is the destination", () => {
39
+ const html = render({ selected: true });
40
+ assert.match(html, /aria-selected="true"/);
41
+ assert.match(html, /text-accent/);
42
+ });
43
+
44
+ it("leaves an unchosen row unselected and unchecked", () => {
45
+ const html = render({});
46
+ assert.match(html, /aria-selected="false"/);
47
+ assert.doesNotMatch(html, /text-accent/);
48
+ });
49
+
50
+ it("marks where the messages live now without disabling anything", () => {
51
+ const html = render({ current: true, currentTag: "current" });
52
+ assert.match(html, /aria-current="true"/);
53
+ assert.match(html, />current</);
54
+ assert.doesNotMatch(html, /disabled/);
55
+ });
56
+
57
+ it("renders a context branch as readable, not operable", () => {
58
+ const html = render({
59
+ context: true,
60
+ ariaLabel: "Travel (containing folder)",
61
+ });
62
+ assert.doesNotMatch(html, /<button/);
63
+ assert.match(html, /<div[^>]*role="treeitem"/);
64
+ assert.match(html, /aria-label="Travel \(containing folder\)"/);
65
+ assert.match(html, /opacity-60/);
66
+ });
67
+
68
+ it("insets the hairline to where the label starts", () => {
69
+ assert.match(render({ separated: true, depth: 1 }), /left:74px/);
70
+ assert.match(
71
+ render({ separated: true, depth: 1, context: true }),
72
+ /left:74px/,
73
+ );
74
+ assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
75
+ });
76
+
77
+ it("takes its place in a roving tab order", () => {
78
+ assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
79
+ assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
80
+ });
81
+ });
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ReactNode } from "react";
3
+ import { FolderRow } from "./folder-row.js";
4
+
5
+ const meta: Meta<typeof FolderRow> = {
6
+ title: "Mail/FolderRow",
7
+ component: FolderRow,
8
+ parameters: { layout: "centered" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof FolderRow>;
13
+
14
+ function List({ children }: { children: ReactNode }) {
15
+ return (
16
+ <div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ export const Closed: Story = {
23
+ name: "Closed",
24
+ render: () => (
25
+ <List>
26
+ <FolderRow
27
+ label="Travel"
28
+ depth={0}
29
+ expanded={false}
30
+ ariaLabel="Move to Travel"
31
+ tabIndex={0}
32
+ />
33
+ </List>
34
+ ),
35
+ };
36
+
37
+ export const Open: Story = {
38
+ name: "Open, with its children indented under it",
39
+ render: () => (
40
+ <List>
41
+ <FolderRow
42
+ label="Travel"
43
+ depth={0}
44
+ expanded
45
+ ariaLabel="Move to Travel"
46
+ separated
47
+ />
48
+ <FolderRow
49
+ label="Hotels"
50
+ depth={1}
51
+ expanded={false}
52
+ ariaLabel="Move to Hotels"
53
+ separated
54
+ />
55
+ <FolderRow
56
+ label="Flights"
57
+ depth={1}
58
+ expanded={false}
59
+ ariaLabel="Move to Flights"
60
+ />
61
+ </List>
62
+ ),
63
+ };
64
+
65
+ export const Selected: Story = {
66
+ name: "The destination",
67
+ render: () => (
68
+ <List>
69
+ <FolderRow
70
+ label="Hotels"
71
+ depth={1}
72
+ expanded={false}
73
+ selected
74
+ ariaLabel="Move to Hotels"
75
+ />
76
+ </List>
77
+ ),
78
+ };
79
+
80
+ /** Where the messages live now: a marker, never a disabled control. */
81
+ export const Current: Story = {
82
+ name: "The folder you are in",
83
+ render: () => (
84
+ <List>
85
+ <FolderRow
86
+ label="Inbox"
87
+ depth={0}
88
+ expanded={false}
89
+ current
90
+ currentTag="current"
91
+ ariaLabel="Inbox (current folder)"
92
+ />
93
+ </List>
94
+ ),
95
+ };
96
+
97
+ /** A branch on screen only to hold the match under it: it reads, it never operates. */
98
+ export const Context: Story = {
99
+ name: "A branch held open by a match below it",
100
+ render: () => (
101
+ <List>
102
+ <FolderRow
103
+ label="Travel"
104
+ depth={0}
105
+ expanded
106
+ context
107
+ ariaLabel="Travel (containing folder)"
108
+ separated
109
+ />
110
+ <FolderRow
111
+ label="Hotels"
112
+ depth={1}
113
+ expanded={false}
114
+ ariaLabel="Move to Hotels"
115
+ />
116
+ </List>
117
+ ),
118
+ };
@@ -0,0 +1,147 @@
1
+ import { Check, ChevronRight, Folder } from "lucide-react";
2
+ import type { Ref } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export const FOLDER_ROW_BASE =
6
+ "flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
7
+
8
+ export const FOLDER_INDENT_STEP = 14;
9
+
10
+ /**
11
+ * Where a row's text starts: `px-3` plus the chevron and folder icon columns
12
+ * with their gaps. The hairline separator is inset to it, so the line begins
13
+ * under the label the way a native mobile list draws it.
14
+ */
15
+ export const FOLDER_ROW_TEXT_INSET = 60;
16
+
17
+ export const FolderRowIndent = ({ depth }: { depth: number }) =>
18
+ depth > 0 ? (
19
+ <span
20
+ aria-hidden="true"
21
+ className="shrink-0"
22
+ style={{ width: depth * FOLDER_INDENT_STEP }}
23
+ />
24
+ ) : null;
25
+
26
+ export const FolderRowSeparator = ({ depth }: { depth: number }) => (
27
+ <span
28
+ aria-hidden="true"
29
+ className="pointer-events-none absolute right-0 bottom-0 h-px bg-line"
30
+ style={{ left: FOLDER_ROW_TEXT_INSET + depth * FOLDER_INDENT_STEP }}
31
+ />
32
+ );
33
+
34
+ export interface FolderRowProps {
35
+ label: string;
36
+ /** Indents the row and carries the nesting to `aria-level`. */
37
+ depth: number;
38
+ /** Its children are on screen. */
39
+ expanded: boolean;
40
+ /** What the row is announced as, e.g. `Move to Archive`. */
41
+ ariaLabel: string;
42
+ /** Chosen as the destination: checked, and announced as selected. */
43
+ selected?: boolean;
44
+ /**
45
+ * On screen only to hold a match below it. A branch reads and never
46
+ * operates, so it is neither focusable nor a target.
47
+ */
48
+ context?: boolean;
49
+ /** Where the messages live now: a marker, never a disabled control. */
50
+ current?: boolean;
51
+ /** Inline tag shown on the current row, e.g. `current`. */
52
+ currentTag?: string;
53
+ /** A hairline under the label, drawn for every row but the last. */
54
+ separated?: boolean;
55
+ tabIndex?: number;
56
+ onActivate?: () => void;
57
+ onFocus?: () => void;
58
+ ref?: Ref<HTMLButtonElement>;
59
+ }
60
+
61
+ /**
62
+ * One folder in a tree: opens where you tap it, and says whether it is the
63
+ * destination, the folder you are in, or a branch on the way to a match.
64
+ */
65
+ export const FolderRow = ({
66
+ label,
67
+ depth,
68
+ expanded,
69
+ ariaLabel,
70
+ selected = false,
71
+ context = false,
72
+ current = false,
73
+ currentTag,
74
+ separated = false,
75
+ tabIndex,
76
+ onActivate,
77
+ onFocus,
78
+ ref,
79
+ }: FolderRowProps) => {
80
+ const chevron = (
81
+ <ChevronRight
82
+ className={cn(
83
+ "size-4 shrink-0 text-fg-subtle transition-transform",
84
+ expanded && "rotate-90",
85
+ )}
86
+ aria-hidden="true"
87
+ />
88
+ );
89
+ const icon = (
90
+ <Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
91
+ );
92
+ if (context) {
93
+ return (
94
+ <div className="relative flex items-center">
95
+ {/* biome-ignore lint/a11y/useFocusableInteractive: an ancestor held on screen by a match below it — a branch, not a destination */}
96
+ <div
97
+ role="treeitem"
98
+ aria-level={depth + 1}
99
+ aria-selected={false}
100
+ aria-expanded={expanded}
101
+ aria-label={ariaLabel}
102
+ className={cn(FOLDER_ROW_BASE, "opacity-60")}
103
+ >
104
+ <FolderRowIndent depth={depth} />
105
+ {chevron}
106
+ {icon}
107
+ <span className="min-w-0 flex-1 truncate">{label}</span>
108
+ </div>
109
+ {separated && <FolderRowSeparator depth={depth} />}
110
+ </div>
111
+ );
112
+ }
113
+ return (
114
+ <div className="relative flex items-center">
115
+ <button
116
+ ref={ref}
117
+ type="button"
118
+ role="treeitem"
119
+ aria-level={depth + 1}
120
+ aria-selected={selected}
121
+ aria-expanded={expanded}
122
+ aria-current={current ? "true" : undefined}
123
+ aria-label={ariaLabel}
124
+ tabIndex={tabIndex}
125
+ onClick={onActivate}
126
+ onFocus={onFocus}
127
+ className={cn(
128
+ FOLDER_ROW_BASE,
129
+ "hover:bg-surface-raised active:bg-surface-sunken",
130
+ current && "text-fg-muted",
131
+ )}
132
+ >
133
+ <FolderRowIndent depth={depth} />
134
+ {chevron}
135
+ {icon}
136
+ <span className="min-w-0 flex-1 truncate">{label}</span>
137
+ {current && currentTag && (
138
+ <span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
139
+ )}
140
+ {selected && (
141
+ <Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
142
+ )}
143
+ </button>
144
+ {separated && <FolderRowSeparator depth={depth} />}
145
+ </div>
146
+ );
147
+ };