@rovula/ui 0.1.28 → 0.1.30

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 (65) hide show
  1. package/dist/cjs/bundle.css +522 -67
  2. package/dist/cjs/bundle.js +589 -589
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/DataTable/DataTable.d.ts +195 -4
  5. package/dist/cjs/types/components/DataTable/DataTable.editing.d.ts +20 -0
  6. package/dist/cjs/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  7. package/dist/cjs/types/components/DataTable/DataTable.stories.d.ts +294 -6
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +22 -0
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  10. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  11. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  12. package/dist/cjs/types/components/Table/Table.d.ts +33 -3
  13. package/dist/cjs/types/components/Table/Table.stories.d.ts +86 -4
  14. package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +8 -0
  15. package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +1 -0
  16. package/dist/components/DataTable/DataTable.editing.js +385 -0
  17. package/dist/components/DataTable/DataTable.editing.types.js +1 -0
  18. package/dist/components/DataTable/DataTable.js +993 -50
  19. package/dist/components/DataTable/DataTable.stories.js +1137 -25
  20. package/dist/components/Dropdown/Dropdown.js +8 -6
  21. package/dist/components/ScrollArea/ScrollArea.js +2 -2
  22. package/dist/components/ScrollArea/ScrollArea.stories.js +68 -2
  23. package/dist/components/Table/Table.js +103 -13
  24. package/dist/components/Table/Table.stories.js +226 -9
  25. package/dist/components/TextInput/TextInput.js +6 -4
  26. package/dist/components/TextInput/TextInput.stories.js +8 -0
  27. package/dist/components/TextInput/TextInput.styles.js +7 -1
  28. package/dist/esm/bundle.css +522 -67
  29. package/dist/esm/bundle.js +1545 -1545
  30. package/dist/esm/bundle.js.map +1 -1
  31. package/dist/esm/types/components/DataTable/DataTable.d.ts +195 -4
  32. package/dist/esm/types/components/DataTable/DataTable.editing.d.ts +20 -0
  33. package/dist/esm/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  34. package/dist/esm/types/components/DataTable/DataTable.stories.d.ts +294 -6
  35. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +22 -0
  36. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  37. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  38. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  39. package/dist/esm/types/components/Table/Table.d.ts +33 -3
  40. package/dist/esm/types/components/Table/Table.stories.d.ts +86 -4
  41. package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +8 -0
  42. package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +1 -0
  43. package/dist/index.d.ts +493 -122
  44. package/dist/src/theme/global.css +775 -96
  45. package/package.json +14 -2
  46. package/src/components/DataTable/DataTable.editing.tsx +861 -0
  47. package/src/components/DataTable/DataTable.editing.types.ts +192 -0
  48. package/src/components/DataTable/DataTable.stories.tsx +2310 -31
  49. package/src/components/DataTable/DataTable.test.tsx +696 -0
  50. package/src/components/DataTable/DataTable.tsx +2275 -94
  51. package/src/components/Dropdown/Dropdown.tsx +22 -6
  52. package/src/components/ScrollArea/ScrollArea.stories.tsx +146 -3
  53. package/src/components/ScrollArea/ScrollArea.tsx +6 -6
  54. package/src/components/Table/Table.stories.tsx +789 -44
  55. package/src/components/Table/Table.tsx +306 -28
  56. package/src/components/TextInput/TextInput.stories.tsx +80 -0
  57. package/src/components/TextInput/TextInput.styles.ts +7 -1
  58. package/src/components/TextInput/TextInput.tsx +21 -14
  59. package/src/test/setup.ts +50 -0
  60. package/src/theme/global.css +81 -42
  61. package/src/theme/presets/colors.js +12 -0
  62. package/src/theme/themes/variable.css +27 -28
  63. package/src/theme/tokens/baseline.css +2 -1
  64. package/src/theme/tokens/components/scrollbar.css +9 -4
  65. package/src/theme/tokens/components/table.css +63 -0
@@ -1,52 +1,161 @@
1
1
  import * as React from "react";
2
-
2
+ import {
3
+ ChevronDown,
4
+ ChevronLeft,
5
+ ChevronRight,
6
+ ChevronFirst,
7
+ ChevronLast,
8
+ } from "lucide-react";
3
9
  import { cn } from "@/utils/cn";
10
+ import ActionButton from "@/components/ActionButton/ActionButton";
4
11
 
12
+ // ---------------------------------------------------------------------------
13
+ // Table — root scroll container + <table>
14
+ // Uses border-separate / border-spacing-0 so border-r on cells works cleanly.
15
+ //
16
+ // Colours adapt automatically when an ancestor carries data-surface="panel".
17
+ // See src/theme/tokens/components/table.css for the token definitions.
18
+ // ---------------------------------------------------------------------------
5
19
  const Table = React.forwardRef<
6
20
  HTMLTableElement,
7
21
  {
8
22
  rootClassName?: string;
9
23
  rootRef?: React.LegacyRef<HTMLDivElement>;
24
+ /**
25
+ * Wraps the table in a rounded frame (`rounded-md` + `border-table-c-border`).
26
+ * Uses an outer `overflow-hidden` shell and an inner scroll area so corners clip
27
+ * cleanly (same pattern as scrollable bordered tables elsewhere in the system).
28
+ * For pagination in the same frame, wrap `Table` + `TablePagination` in your own
29
+ * bordered div instead of relying on this prop alone.
30
+ */
31
+ bordered?: boolean;
32
+ /**
33
+ * When false, render only `<table>` (no inner `overflow-auto` wrapper).
34
+ * Use when a parent already provides the scrollport — e.g. `DataTable` — so
35
+ * `sticky` header rows stick to the correct ancestor.
36
+ * Ignored when `bordered` is true (bordered tables always use the inner scroll shell).
37
+ */
38
+ scrollableWrapper?: boolean;
10
39
  } & React.HTMLAttributes<HTMLTableElement>
11
- >(({ rootClassName, className, rootRef, ...props }, ref) => (
12
- <div
13
- className={cn("relative h-full w-full overflow-auto", rootClassName)}
14
- ref={rootRef}
15
- >
16
- <table
17
- ref={ref}
18
- className={cn("w-full caption-bottom text-sm border-collapse", className)}
19
- {...props}
20
- />
21
- </div>
22
- ));
40
+ >(
41
+ (
42
+ {
43
+ rootClassName,
44
+ className,
45
+ rootRef,
46
+ bordered = false,
47
+ scrollableWrapper = true,
48
+ ...props
49
+ },
50
+ ref,
51
+ ) => {
52
+ const scrollClassName = cn(
53
+ "relative h-full w-full min-h-0 overflow-auto",
54
+ "ui-scrollbar ui-scrollbar-x-m ui-scrollbar-y-s",
55
+ );
56
+
57
+ const tableClassName = cn(
58
+ "min-w-full caption-bottom border-separate border-spacing-0",
59
+ className,
60
+ );
61
+
62
+ const tableEl = <table ref={ref} className={tableClassName} {...props} />;
63
+
64
+ if (!bordered && scrollableWrapper === false) {
65
+ return (
66
+ <table
67
+ ref={ref}
68
+ className={cn(tableClassName, rootClassName)}
69
+ {...props}
70
+ />
71
+ );
72
+ }
23
73
 
74
+ if (bordered) {
75
+ return (
76
+ <div
77
+ className={cn(
78
+ "relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-md border border-table-c-border",
79
+ rootClassName,
80
+ )}
81
+ ref={rootRef}
82
+ >
83
+ <div className={scrollClassName}>{tableEl}</div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ return (
89
+ <div className={cn(scrollClassName, rootClassName)} ref={rootRef}>
90
+ {tableEl}
91
+ </div>
92
+ );
93
+ },
94
+ );
24
95
  Table.displayName = "Table";
25
96
 
97
+ // ---------------------------------------------------------------------------
98
+ // TableHeader
99
+ // The header-to-body separator resolves from --table-c-header-line:
100
+ // default → transparent (bg contrast between table-bg-main and row-bg
101
+ // provides visual separation — no explicit line needed)
102
+ // panel → table-panel-main-line (all bgs are transparent, explicit
103
+ // line required)
104
+ // ---------------------------------------------------------------------------
26
105
  const TableHeader = React.forwardRef<
27
106
  HTMLTableSectionElement,
28
107
  React.HTMLAttributes<HTMLTableSectionElement>
29
108
  >(({ className, ...props }, ref) => (
30
109
  <thead
31
110
  ref={ref}
32
- className={cn("[&_tr]:border-b bg-secondary-80", className)}
111
+ className={cn(
112
+ "[&_tr>th]:box-border [&_tr>th]:border-b [&_tr>th]:border-b-table-c-header-line",
113
+ className,
114
+ )}
33
115
  {...props}
34
116
  />
35
117
  ));
36
118
  TableHeader.displayName = "TableHeader";
37
119
 
120
+ // ---------------------------------------------------------------------------
121
+ // TableBody
122
+ // striped=true → alternating bg colours (odd=row-bg, even=row-bg-even),
123
+ // NO horizontal row strokes
124
+ // striped=false → single row-bg colour, row strokes via TableRow divided prop
125
+ //
126
+ // Hover, selected, and bg colours all resolve from table-c-* tokens so they
127
+ // automatically switch when inside a [data-surface="panel"] ancestor.
128
+ // ---------------------------------------------------------------------------
38
129
  const TableBody = React.forwardRef<
39
130
  HTMLTableSectionElement,
40
- React.HTMLAttributes<HTMLTableSectionElement>
41
- >(({ className, ...props }, ref) => (
131
+ { striped?: boolean } & React.HTMLAttributes<HTMLTableSectionElement>
132
+ >(({ className, striped = false, ...props }, ref) => (
42
133
  <tbody
43
134
  ref={ref}
44
- className={cn("[&_tr:last-child]:border-0", className)}
135
+ className={cn(
136
+ // Remove the bottom border on the very last row's cells in every mode.
137
+ "[&_tr:last-child>td]:border-b-0 [&_tr:last-child>th]:border-b-0",
138
+ "[&_tr]:bg-table-c-row-bg",
139
+ // !important beats nth-child specificity so hover always wins.
140
+ "[&_tr:hover]:!bg-table-c-hover",
141
+ "[&_tr[data-state=selected]]:!bg-table-c-selected",
142
+ "[&_tr[data-highlighted=true]]:!bg-table-c-selected",
143
+ striped && [
144
+ "[&_tr:nth-child(odd)]:bg-table-c-row-bg",
145
+ "[&_tr:nth-child(even)]:bg-table-c-row-bg-even",
146
+ // Remove row stroke — colour alternation provides separation.
147
+ "[&_tr>td]:border-b-0",
148
+ ],
149
+ className,
150
+ )}
45
151
  {...props}
46
152
  />
47
153
  ));
48
154
  TableBody.displayName = "TableBody";
49
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // TableFooter
158
+ // ---------------------------------------------------------------------------
50
159
  const TableFooter = React.forwardRef<
51
160
  HTMLTableSectionElement,
52
161
  React.HTMLAttributes<HTMLTableSectionElement>
@@ -54,29 +163,53 @@ const TableFooter = React.forwardRef<
54
163
  <tfoot
55
164
  ref={ref}
56
165
  className={cn(
57
- "border-t bg-transparent-grey2-8 font-medium [&>tr]:last:border-b-0",
58
- className
166
+ "bg-table-c-header-bg border-t border-t-table-c-header-line font-medium [&>tr]:last:border-b-0",
167
+ className,
59
168
  )}
60
169
  {...props}
61
170
  />
62
171
  ));
63
172
  TableFooter.displayName = "TableFooter";
64
173
 
174
+ // ---------------------------------------------------------------------------
175
+ // TableRow
176
+ // divided=true → horizontal separator on child td/th cells (border-b)
177
+ // NOTE: with border-separate tables, borders on <tr> are
178
+ // invisible — must target cells directly via CSS selectors.
179
+ // divided=false → no separator (striped rows / empty row)
180
+ // colDivided=true → vertical column dividers on every non-last td / th child
181
+ // ---------------------------------------------------------------------------
65
182
  const TableRow = React.forwardRef<
66
183
  HTMLTableRowElement,
67
- React.HTMLAttributes<HTMLTableRowElement>
68
- >(({ className, ...props }, ref) => (
184
+ {
185
+ divided?: boolean;
186
+ colDivided?: boolean;
187
+ } & React.HTMLAttributes<HTMLTableRowElement>
188
+ >(({ className, divided = true, colDivided = false, ...props }, ref) => (
69
189
  <tr
70
190
  ref={ref}
71
191
  className={cn(
72
- "border-b transition-colors hover:bg-transparent-grey2-8 data-[state=selected]:bg-grey-20",
73
- className
192
+ "transition-colors box-border",
193
+ // Row separator — applied on cells because border-separate ignores tr borders
194
+ divided && [
195
+ "[&>td]:border-b [&>td]:border-b-table-c-row-line",
196
+ "[&>th]:border-b [&>th]:border-b-table-c-row-line",
197
+ ],
198
+ // Column dividers on every non-last cell
199
+ colDivided && [
200
+ "[&>td:not(:last-child)]:border-r [&>td:not(:last-child)]:border-r-table-c-col-line",
201
+ "[&>th:not(:last-child)]:border-r [&>th:not(:last-child)]:border-r-table-c-col-line",
202
+ ],
203
+ className,
74
204
  )}
75
205
  {...props}
76
206
  />
77
207
  ));
78
208
  TableRow.displayName = "TableRow";
79
209
 
210
+ // ---------------------------------------------------------------------------
211
+ // TableHead (th)
212
+ // ---------------------------------------------------------------------------
80
213
  const TableHead = React.forwardRef<
81
214
  HTMLTableCellElement,
82
215
  React.ThHTMLAttributes<HTMLTableCellElement>
@@ -84,14 +217,23 @@ const TableHead = React.forwardRef<
84
217
  <th
85
218
  ref={ref}
86
219
  className={cn(
87
- " h-12 py-3 px-6 text-left align-middle typography-body2 text-text-g-contrast-low [&:has([role=checkbox])]:pr-4 [&:has([role=checkbox])]:w-4",
88
- className
220
+ "box-border py-3 px-3 text-left align-middle",
221
+ "typography-body2 text-text-contrast-max",
222
+ "bg-table-c-header-bg",
223
+ "leading-[20px]",
224
+ // Prefer min-width only so callers (e.g. DataTable exactWidth) can set
225
+ // a different width via inline style without fighting a fixed `w-10`.
226
+ "[&:has([role=checkbox])]:px-3 [&:has([role=checkbox])]:min-w-10",
227
+ className,
89
228
  )}
90
229
  {...props}
91
230
  />
92
231
  ));
93
232
  TableHead.displayName = "TableHead";
94
233
 
234
+ // ---------------------------------------------------------------------------
235
+ // TableCell (td)
236
+ // ---------------------------------------------------------------------------
95
237
  const TableCell = React.forwardRef<
96
238
  HTMLTableCellElement,
97
239
  React.TdHTMLAttributes<HTMLTableCellElement>
@@ -99,26 +241,161 @@ const TableCell = React.forwardRef<
99
241
  <td
100
242
  ref={ref}
101
243
  className={cn(
102
- " py-3 px-6 text-left align-middle typography-body3 text-text-g-contrast-low [&:has([role=checkbox])]:pr-4 [&:has([role=checkbox])]:w-4",
103
- className
244
+ "box-border py-3 px-3 text-left align-middle",
245
+ "typography-body3 text-text-contrast-max",
246
+ "leading-[18px]", // content สูง 18
247
+ // Inherit row background from <tr> so every cell paints the same fill
248
+ // (some table layouts / browsers leave the last column visually “empty”).
249
+ "bg-inherit",
250
+ "[&:has([role=checkbox])]:px-3 [&:has([role=checkbox])]:min-w-10",
251
+ className,
104
252
  )}
105
253
  {...props}
106
254
  />
107
255
  ));
108
256
  TableCell.displayName = "TableCell";
109
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // TableCaption
260
+ // ---------------------------------------------------------------------------
110
261
  const TableCaption = React.forwardRef<
111
262
  HTMLTableCaptionElement,
112
263
  React.HTMLAttributes<HTMLTableCaptionElement>
113
264
  >(({ className, ...props }, ref) => (
114
265
  <caption
115
266
  ref={ref}
116
- className={cn("mt-4 text-sm text-text-g-contrast-medium", className)}
267
+ className={cn("mt-4 pb-3 text-sm text-text-g-contrast-medium", className)}
117
268
  {...props}
118
269
  />
119
270
  ));
120
271
  TableCaption.displayName = "TableCaption";
121
272
 
273
+ // ---------------------------------------------------------------------------
274
+ // TablePagination
275
+ // Resolves bg and top-border from the same table-c-* tokens so it adapts
276
+ // automatically — no variant prop needed.
277
+ // default → bg-table-c-header-bg (= table-bg-main), border transparent
278
+ // panel → bg transparent, border table-panel-main-line
279
+ // ---------------------------------------------------------------------------
280
+ export interface TablePaginationProps {
281
+ pageIndex: number;
282
+ pageSize: number;
283
+ totalCount: number;
284
+ pageSizeOptions?: number[];
285
+ onPageChange: (pageIndex: number) => void;
286
+ onPageSizeChange: (pageSize: number) => void;
287
+ className?: string;
288
+ }
289
+
290
+ const TablePagination = React.forwardRef<HTMLDivElement, TablePaginationProps>(
291
+ (
292
+ {
293
+ pageIndex,
294
+ pageSize,
295
+ totalCount,
296
+ pageSizeOptions = [10, 20, 50, 100],
297
+ onPageChange,
298
+ onPageSizeChange,
299
+ className,
300
+ },
301
+ ref,
302
+ ) => {
303
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
304
+ const from = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
305
+ const to = Math.min((pageIndex + 1) * pageSize, totalCount);
306
+
307
+ return (
308
+ <div
309
+ ref={ref}
310
+ className={cn(
311
+ "flex items-center justify-end gap-8 px-6 py-2",
312
+ "bg-table-c-header-bg",
313
+ "border-t border-t-table-c-header-line",
314
+ className,
315
+ )}
316
+ >
317
+ {/* Items per page */}
318
+ <div className="flex items-center gap-3">
319
+ <span className="typography-subtitle4 text-text-g-contrast-high whitespace-nowrap">
320
+ items per page
321
+ </span>
322
+ <div className="relative">
323
+ <select
324
+ value={pageSize}
325
+ onChange={(e) => {
326
+ onPageSizeChange(Number(e.target.value));
327
+ onPageChange(0);
328
+ }}
329
+ className={cn(
330
+ "appearance-none w-[72px] rounded-md border border-input-default-stroke",
331
+ "bg-table-c-header-bg p-2 pr-7",
332
+ "typography-small2 text-text-g-contrast-high",
333
+ "cursor-pointer focus:outline-none focus:ring-1 focus:ring-input-active-stroke",
334
+ )}
335
+ >
336
+ {pageSizeOptions.map((size) => (
337
+ <option key={size} value={size}>
338
+ {size}
339
+ </option>
340
+ ))}
341
+ </select>
342
+ <ChevronDown
343
+ className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 size-[14px] text-text-g-contrast-high"
344
+ aria-hidden
345
+ />
346
+ </div>
347
+ </div>
348
+
349
+ {/* Range label */}
350
+ <span className="typography-subtitle4 text-text-g-contrast-high whitespace-nowrap tabular-nums">
351
+ {from}–{to} of {totalCount} items
352
+ </span>
353
+
354
+ {/* Page navigation */}
355
+ <div className="flex items-center gap-2">
356
+ <ActionButton
357
+ variant="icon"
358
+ size="sm"
359
+ onClick={() => onPageChange(0)}
360
+ disabled={pageIndex === 0}
361
+ aria-label="First page"
362
+ >
363
+ <ChevronFirst />
364
+ </ActionButton>
365
+ <ActionButton
366
+ variant="icon"
367
+ size="sm"
368
+ onClick={() => onPageChange(pageIndex - 1)}
369
+ disabled={pageIndex === 0}
370
+ aria-label="Previous page"
371
+ >
372
+ <ChevronLeft />
373
+ </ActionButton>
374
+ <ActionButton
375
+ variant="icon"
376
+ size="sm"
377
+ onClick={() => onPageChange(pageIndex + 1)}
378
+ disabled={pageIndex >= totalPages - 1}
379
+ aria-label="Next page"
380
+ >
381
+ <ChevronRight />
382
+ </ActionButton>
383
+ <ActionButton
384
+ variant="icon"
385
+ size="sm"
386
+ onClick={() => onPageChange(totalPages - 1)}
387
+ disabled={pageIndex >= totalPages - 1}
388
+ aria-label="Last page"
389
+ >
390
+ <ChevronLast />
391
+ </ActionButton>
392
+ </div>
393
+ </div>
394
+ );
395
+ },
396
+ );
397
+ TablePagination.displayName = "TablePagination";
398
+
122
399
  export {
123
400
  Table,
124
401
  TableHeader,
@@ -128,4 +405,5 @@ export {
128
405
  TableRow,
129
406
  TableCell,
130
407
  TableCaption,
408
+ TablePagination,
131
409
  };
@@ -45,6 +45,86 @@ export const Default = {
45
45
  },
46
46
  } satisfies StoryObj;
47
47
 
48
+ /**
49
+ * `isFloatingLabel={false}` — visible **`placeholder`**, no floating animation,
50
+ * and no inner `<label>` when `label` is empty (e.g. table cells, compact filters).
51
+ * Pair with `required={false}` + `hasClearIcon={false}` when mimicking inline fields.
52
+ */
53
+ export const NonFloatingLabel = {
54
+ render: () => (
55
+ <div className="flex w-full max-w-4xl flex-col gap-8">
56
+ <div className="flex flex-col gap-3">
57
+ <h4 className="typography-subtitle4 text-text-g-contrast-medium">
58
+ Placeholder only — empty <code className="typography-small2">label</code>{" "}
59
+ (no floating label, no label node)
60
+ </h4>
61
+ <p className="typography-small2 text-text-g-contrast-low max-w-2xl">
62
+ Tab order follows DOM, like native inputs in a row.
63
+ </p>
64
+ <div className="flex flex-row flex-wrap items-end gap-4">
65
+ <TextInput
66
+ id="nf-cell-lg"
67
+ size="lg"
68
+ isFloatingLabel={false}
69
+ label=""
70
+ required={false}
71
+ hasClearIcon={false}
72
+ keepFooterSpace={false}
73
+ placeholder="Column name"
74
+ fullwidth={false}
75
+ aria-label="Column name"
76
+ />
77
+ <TextInput
78
+ id="nf-cell-md"
79
+ size="md"
80
+ isFloatingLabel={false}
81
+ label=""
82
+ required={false}
83
+ hasClearIcon={false}
84
+ keepFooterSpace={false}
85
+ placeholder="Column name"
86
+ fullwidth={false}
87
+ aria-label="Column name"
88
+ />
89
+ <TextInput
90
+ id="nf-cell-sm"
91
+ size="sm"
92
+ isFloatingLabel={false}
93
+ label=""
94
+ required={false}
95
+ hasClearIcon={false}
96
+ keepFooterSpace={false}
97
+ placeholder="Column name"
98
+ fullwidth={false}
99
+ aria-label="Column name"
100
+ />
101
+ </div>
102
+ </div>
103
+
104
+ <div className="flex flex-col gap-3">
105
+ <h4 className="typography-subtitle4 text-text-g-contrast-medium">
106
+ Static label inside field (non-floating layout)
107
+ </h4>
108
+ <div className="flex flex-row flex-wrap gap-4">
109
+ <TextInput
110
+ id="nf-static-md"
111
+ size="md"
112
+ isFloatingLabel={false}
113
+ label="Department"
114
+ required={false}
115
+ hasClearIcon
116
+ keepFooterSpace
117
+ placeholder="e.g. Engineering"
118
+ helperText="Shown as fixed label + real placeholder."
119
+ fullwidth={false}
120
+ className="min-w-[240px]"
121
+ />
122
+ </div>
123
+ </div>
124
+ </div>
125
+ ),
126
+ } satisfies StoryObj;
127
+
48
128
  const InputWithRef = (props: any) => {
49
129
  const inputRef = useRef<HTMLInputElement | null>(null);
50
130
 
@@ -5,10 +5,15 @@ export const inputVariant = cva(
5
5
  "truncate",
6
6
  "border-0 outline-none",
7
7
  "flex w-auto box-border",
8
- "peer text-input-filled-text placeholder:text-transparent bg-transparent caret-primary",
8
+ "peer text-input-filled-text bg-transparent caret-primary",
9
9
  ],
10
10
  {
11
11
  variants: {
12
+ /** When `true`, placeholder is transparent — floating label uses a space placeholder. */
13
+ floatingLabelPlaceholder: {
14
+ true: "placeholder:text-transparent",
15
+ false: "placeholder:text-input-default-text",
16
+ },
12
17
  size: {
13
18
  sm: "p-2 px-3 typography-small1",
14
19
  md: "py-2 px-3 typography-subtitle4",
@@ -144,6 +149,7 @@ export const inputVariant = cva(
144
149
  hasSearchIcon: false,
145
150
  leftSectionIcon: false, // TODO function style
146
151
  rightSectionIcon: false,
152
+ floatingLabelPlaceholder: true,
147
153
  },
148
154
  },
149
155
  );
@@ -74,7 +74,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
74
74
  rounded = "normal",
75
75
  variant = "outline",
76
76
  type = "text",
77
- iconMode = "solid",
77
+ iconMode = "flat",
78
78
  helperText,
79
79
  errorMessage,
80
80
  warningMessage,
@@ -101,6 +101,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
101
101
  format,
102
102
  trimOnCommit,
103
103
  normalizeOnCommit,
104
+ placeholder,
104
105
  ...props
105
106
  },
106
107
  ref,
@@ -143,6 +144,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
143
144
  (iconMode === "flat" && hasRightSectionIcon) || hasClearIcon,
144
145
  leftSectionIcon: iconMode === "solid" ? hasLeftSectionIcon : false,
145
146
  rightSectionIcon: iconMode === "solid" ? hasRightSectionIcon : false,
147
+ floatingLabelPlaceholder: isFloatingLabel,
146
148
  });
147
149
  const labelClassname = labelVariant({
148
150
  size,
@@ -377,12 +379,15 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
377
379
  handleOnClickRightSectionIcon,
378
380
  ]);
379
381
 
382
+ const showLabel =
383
+ isFloatingLabel || (label != null && String(label) !== "");
384
+
380
385
  return (
381
386
  <div className={`inline-flex flex-col ${fullwidth ? "w-full" : ""}`}>
382
387
  <div className="relative">
383
388
  <input
384
389
  {...props}
385
- placeholder=" "
390
+ placeholder={isFloatingLabel ? " " : placeholder ?? ""}
386
391
  ref={stableRef}
387
392
  type={type}
388
393
  id={_id}
@@ -435,18 +440,20 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
435
440
  )}
436
441
  {endIconElement}
437
442
 
438
- <label htmlFor={_id} className={cn(labelClassname)}>
439
- {label}{" "}
440
- {required && (
441
- <span
442
- className={cn("text-input-error", {
443
- "text-input-disable-text": disabled,
444
- })}
445
- >
446
- *
447
- </span>
448
- )}
449
- </label>
443
+ {showLabel && (
444
+ <label htmlFor={_id} className={cn(labelClassname, labelClassName)}>
445
+ {label}{" "}
446
+ {required && (
447
+ <span
448
+ className={cn("text-input-error", {
449
+ "text-input-disable-text": disabled,
450
+ })}
451
+ >
452
+ *
453
+ </span>
454
+ )}
455
+ </label>
456
+ )}
450
457
  </div>
451
458
  {(feedbackMessage || keepFooterSpace) && (
452
459
  <span className={helperTextClassname}>
@@ -0,0 +1,50 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+ import { cleanup } from "@testing-library/react";
3
+ import { afterEach } from "vitest";
4
+
5
+ afterEach(() => {
6
+ cleanup();
7
+ });
8
+
9
+ /* jsdom: scrollTo must update scrollTop or TanStack Virtual keeps scrollOffset > 0 */
10
+ Element.prototype.scrollTo = function scrollTo(
11
+ this: Element,
12
+ arg?: ScrollToOptions | number,
13
+ y?: number,
14
+ ): void {
15
+ if (!(this instanceof HTMLElement)) {
16
+ return;
17
+ }
18
+ if (typeof arg === "object" && arg != null && "top" in arg) {
19
+ const t = (arg as { top?: number }).top;
20
+ this.scrollTop = typeof t === "number" ? t : 0;
21
+ return;
22
+ }
23
+ if (typeof arg === "number") {
24
+ this.scrollTop = typeof y === "number" ? y : arg;
25
+ }
26
+ };
27
+ if (typeof HTMLElement.prototype.scrollIntoView !== "function") {
28
+ HTMLElement.prototype.scrollIntoView = function scrollIntoView() {
29
+ /* no-op */
30
+ };
31
+ }
32
+
33
+ /**
34
+ * TanStack Virtual subscribes to ResizeObserver; a no-op observe() never
35
+ * re-measures after layout. Schedule an initial callback so scrollport size
36
+ * updates after mount (still relies on getBoundingClientRect / layout mocks in tests).
37
+ */
38
+ globalThis.ResizeObserver = class ResizeObserver {
39
+ constructor(private readonly cb: ResizeObserverCallback) {}
40
+
41
+ observe(): void {
42
+ queueMicrotask(() => {
43
+ this.cb([], this as unknown as ResizeObserver);
44
+ });
45
+ }
46
+
47
+ unobserve(): void {}
48
+
49
+ disconnect(): void {}
50
+ };