aplance-ui 1.4.0 → 1.5.1

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 (38) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover.module.css +4 -2
  6. package/registry/components/primitives/popover/popover.tsx +16 -17
  7. package/registry/components/primitives/scroll-rail/scroll-rail.module.css +148 -0
  8. package/registry/components/primitives/scroll-rail/scroll-rail.tsx +214 -0
  9. package/registry/components/ui/accordion/accordion.module.css +52 -0
  10. package/registry/components/ui/accordion/accordion.tsx +97 -25
  11. package/registry/components/ui/avatar/avatar.tsx +2 -2
  12. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  13. package/registry/components/ui/button/button.module.css +43 -5
  14. package/registry/components/ui/button/button.tsx +2 -1
  15. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  16. package/registry/components/ui/data-table/data-table.module.css +68 -25
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1229
  18. package/registry/components/ui/data-table/filters.tsx +21 -0
  19. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  20. package/registry/components/ui/data-table/types.ts +11 -1
  21. package/registry/components/ui/data-table/useDataTable.tsx +1 -2
  22. package/registry/components/ui/data-table/utils.ts +4 -3
  23. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  24. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  25. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
  26. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
  27. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
  28. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  29. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  30. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  31. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  32. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  33. package/registry/components/ui/tabs/tabs.module.css +8 -12
  34. package/registry/components/ui/tabs/tabs.tsx +75 -44
  35. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  36. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  37. package/registry/lib/string.ts +3 -3
  38. package/registry/registry.json +32 -3
@@ -0,0 +1,21 @@
1
+ import { useMediaQuery } from "../../../hooks/useMediaQuery";
2
+ import { FilterBar } from "./filter-bar";
3
+ import { FilterModal } from "./filter-modal";
4
+ import type { FilterBarProps } from "./types";
5
+
6
+ // Below this width an inline filter row can't fit its fields (matches the
7
+ // `--md` custom-media token in styles/media.css): the filters collapse into a
8
+ // single "Filters" button and a modal instead of stacking full-height.
9
+ const FILTER_COMPACT_QUERY = "(max-width: 900px)";
10
+
11
+ /**
12
+ * A list's filters: the inline <FilterBar> while it fits, the <FilterModal>
13
+ * on a narrower screen. DataTable shows its filters through it; a list built
14
+ * on its own uses it the same way.
15
+ */
16
+ export const Filters = (props: FilterBarProps) =>
17
+ useMediaQuery(FILTER_COMPACT_QUERY) ? (
18
+ <FilterModal {...props} />
19
+ ) : (
20
+ <FilterBar {...props} />
21
+ );
@@ -8,6 +8,7 @@ import clsx from "clsx";
8
8
  import { Button } from "../button/button";
9
9
  import { CustomSelect } from "../input-fields/custom-select/custom-select";
10
10
  import { Loader } from "../loader/loader";
11
+ import { ScrollRail } from "../../primitives/scroll-rail/scroll-rail";
11
12
  import type { PaginationBarProps } from "./types";
12
13
  import s from "./data-table.module.css";
13
14
 
@@ -28,6 +29,7 @@ export const PaginationBar = <TData,>({
28
29
  rowLabel,
29
30
  rowsSelectable,
30
31
  selectedCount,
32
+ label,
31
33
  extra,
32
34
  }: PaginationBarProps<TData>) => {
33
35
  const pageCount = table.getPageCount();
@@ -42,6 +44,7 @@ export const PaginationBar = <TData,>({
42
44
  const pageNavItems = [
43
45
  {
44
46
  key: "first",
47
+ edge: true,
45
48
  label: "First page",
46
49
  icon: <IconChevronsLeft size={14} stroke={2} />,
47
50
  onClick: () => goTo(0),
@@ -63,6 +66,7 @@ export const PaginationBar = <TData,>({
63
66
  },
64
67
  {
65
68
  key: "last",
69
+ edge: true,
66
70
  label: "Last page",
67
71
  icon: <IconChevronsRight size={14} stroke={2} />,
68
72
  onClick: () => goTo(pageCount - 1),
@@ -111,11 +115,9 @@ export const PaginationBar = <TData,>({
111
115
  );
112
116
 
113
117
  return (
114
- <div
115
- className={clsx(s.pagination_bar, isLoading && s.pagination_bar_loading)}
116
- aria-busy={isLoading || undefined}
117
- >
118
- {/* left - count + optional bulk-action extra slot */}
118
+ <div className={s.pagination_bar} aria-busy={isLoading || undefined}>
119
+ {/* left - the count (or the caller's label), then the extra slot,
120
+ which scrolls on its own while the count stays put */}
119
121
  <div
120
122
  className={clsx(
121
123
  s.pagination_left,
@@ -131,18 +133,26 @@ export const PaginationBar = <TData,>({
131
133
  ) : (
132
134
  <>
133
135
  <p className={s.pagination_count}>
134
- {rowsSelectable && selectedCount > 0 ? (
135
- <>
136
- <span>{selectedCount}</span> of <span>{total}</span>{" "}
137
- {rowLabel}(s) selected
138
- </>
139
- ) : (
140
- <>
141
- <span>{total}</span> {rowLabel}(s)
142
- </>
143
- )}
136
+ {label ??
137
+ (rowsSelectable && selectedCount > 0 ? (
138
+ <>
139
+ <span>{selectedCount}</span> of <span>{total}</span>{" "}
140
+ {rowLabel}(s) selected
141
+ </>
142
+ ) : (
143
+ <>
144
+ <span>{total}</span> {rowLabel}(s)
145
+ </>
146
+ ))}
144
147
  </p>
145
- {extra}
148
+ {extra ? (
149
+ <ScrollRail
150
+ className={s.pagination_extra}
151
+ contentClassName={s.pagination_extra_items}
152
+ >
153
+ {extra}
154
+ </ScrollRail>
155
+ ) : null}
146
156
  </>
147
157
  )}
148
158
  </div>
@@ -205,7 +215,10 @@ export const PaginationBar = <TData,>({
205
215
  <Loader
206
216
  key={`${item.key}-skeleton`}
207
217
  variant="skeleton"
208
- className={s.pagination_button_skeleton}
218
+ className={clsx(
219
+ s.pagination_button_skeleton,
220
+ item.edge && s.page_nav_edge,
221
+ )}
209
222
  />
210
223
  ))
211
224
  : pageNavItems.map((item) => (
@@ -213,6 +226,7 @@ export const PaginationBar = <TData,>({
213
226
  key={item.key}
214
227
  variant="bordered"
215
228
  layout="box"
229
+ className={item.edge ? s.page_nav_edge : undefined}
216
230
  onClick={item.onClick}
217
231
  disabled={item.disabled}
218
232
  aria-label={item.label}
@@ -159,6 +159,7 @@ export interface PaginationBarProps<TData> {
159
159
  rowLabel: string;
160
160
  rowsSelectable: boolean;
161
161
  selectedCount: number;
162
+ label: ReactNode;
162
163
  extra: ReactNode;
163
164
  }
164
165
 
@@ -298,10 +299,19 @@ export interface DataTableProps<TData extends { id: string }> {
298
299
 
299
300
  /**
300
301
  * Slot rendered inside the pagination bar next to the count text.
301
- * Perfect for bulk-action buttons. Does not shift table layout.
302
+ * Perfect for bulk-action buttons. Does not shift table layout: when it
303
+ * does not fit, it scrolls across on its own (a ScrollRail) while the count
304
+ * stays put.
302
305
  */
303
306
  paginationExtra?: ReactNode;
304
307
 
308
+ /**
309
+ * Replaces the pagination bar's count text, e.g. for a selection that
310
+ * reaches past the loaded rows ("All 120 files selected"). Numbers wrapped
311
+ * in a `<span>` are emphasised like the default count's.
312
+ */
313
+ paginationLabel?: ReactNode;
314
+
305
315
  className?: string;
306
316
  }
307
317
 
@@ -260,8 +260,7 @@ export function useDataTable<TData extends { id: string }>({
260
260
  return;
261
261
  }
262
262
  const next = [...prev];
263
- const [moved] = next.splice(draggedIdx, 1);
264
- next.splice(targetIdx, 0, moved);
263
+ next.splice(targetIdx, 0, ...next.splice(draggedIdx, 1));
265
264
  localDataRef.current = next;
266
265
  setLocalData(next);
267
266
  onDataChange?.(next);
@@ -358,9 +358,10 @@ export function writeSearchParams(
358
358
  params.delete(p(PARAM_KEYS.pageSize));
359
359
  }
360
360
 
361
- if (state.sorting && state.sorting.length > 0) {
362
- params.set(p(PARAM_KEYS.sortBy), state.sorting[0].id);
363
- params.set(p(PARAM_KEYS.sortDir), state.sorting[0].desc ? "desc" : "asc");
361
+ const sort = state.sorting?.[0];
362
+ if (sort) {
363
+ params.set(p(PARAM_KEYS.sortBy), sort.id);
364
+ params.set(p(PARAM_KEYS.sortDir), sort.desc ? "desc" : "asc");
364
365
  } else {
365
366
  params.delete(p(PARAM_KEYS.sortBy));
366
367
  params.delete(p(PARAM_KEYS.sortDir));
@@ -1,6 +1,9 @@
1
- .container {
1
+ .container {
2
2
  width: 100%;
3
- min-width: fit-content;
3
+ /* A field takes the width its layout gives it, never its content's: a long
4
+ value truncates instead of pushing the field over its neighbour in a grid
5
+ column or a flex row. */
6
+ min-width: 0;
4
7
  display: flex;
5
8
  flex-direction: column;
6
9
  gap: var(--spacing-2);
@@ -248,6 +251,9 @@
248
251
  Keep these after .field_default so they reliably override base paddings. */
249
252
  .field_full_width {
250
253
  width: 100% !important;
254
+ /* Fills the field and no more: a button is otherwise never narrower than
255
+ its label (`--button-min-width`), which would defeat `.truncate_text`. */
256
+ --button-min-width: 0;
251
257
  }
252
258
 
253
259
  .field_wrapper_fit {
@@ -10,6 +10,10 @@
10
10
 
11
11
  .picker_popover_trigger {
12
12
  width: 100%;
13
+ /* A field's trigger takes the field's width, never its value's: Popover
14
+ keeps a trigger at least as wide as its content (right for a plain
15
+ button), which would push a long value over the next field. */
16
+ --popover-trigger-min-width: 0;
13
17
  }
14
18
 
15
19
  .picker_popover_menu_base {
@@ -1,87 +1,118 @@
1
- /* uses input-field-common (.container and all)
2
- + popover related stuff
3
- */
4
-
5
- /* actual menu selector */
6
- .popover_menu {
7
- --picker-popover-menu-width: 100%;
8
- width: 100%;
9
- border-radius: calc(var(--radius-md) + var(--spacing-2));
10
-
11
- /* Search container */
12
- .search_container {
13
- padding: var(--spacing-4);
14
- border-bottom: 1px solid var(--color-border);
15
- }
16
-
17
- /* Options container */
18
- .options_container {
19
- width: 100%;
20
- max-height: 16rem;
21
- overflow-y: auto;
22
- padding: var(--spacing-2);
23
- display: flex;
24
- flex-direction: column;
25
- gap: var(--spacing-2);
26
-
27
- .no_options {
28
- padding: var(--spacing-4);
29
- text-align: center;
30
- color: var(--color-muted-foreground);
31
- font-size: var(--font-size-sm);
32
- }
33
-
34
- /* Option button */
35
- .option {
36
- width: 100%;
37
- /* button base is min-width: fit-content - a long label must truncate
38
- inside the row, never widen it past the menu */
39
- min-width: 0;
40
- /* button has justify-content: space-between */
41
- justify-content: flex-start;
42
- /* modifying padding of actual button variant for consistency */
43
- padding: var(--spacing-3) var(--spacing-4);
44
- border-radius: var(--radius-md);
45
-
46
- /* > icon */
47
-
48
- /* The label owns the row's slack space so trailing affordances
49
- (.option_delete) always sit at the row's end - layout-driven, no
50
- auto-margins (those tie with the Icon primitive's own margin reset
51
- and the winner would depend on CSS bundle order). */
52
- .option_label {
53
- flex: 1 1 auto;
54
- min-width: 0;
55
- text-align: start;
56
- }
57
- }
58
- }
59
- }
60
-
61
- .option_label {
62
- max-width: 100% !important;
63
- }
64
-
1
+ /* uses input-field-common (.container and all)
2
+ + popover related stuff
3
+ */
4
+
5
+ /* actual menu selector */
6
+ .popover_menu {
7
+ --picker-popover-menu-width: 100%;
8
+ width: 100%;
9
+ border-radius: calc(var(--radius-md) + var(--spacing-2));
10
+
11
+ /* Search container */
12
+ .search_container {
13
+ padding: var(--spacing-4);
14
+ border-bottom: 1px solid var(--color-border);
15
+ }
16
+
17
+ /* Options container */
18
+ .options_container {
19
+ width: 100%;
20
+ max-height: 16rem;
21
+ overflow-y: auto;
22
+ padding: var(--spacing-2);
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--spacing-2);
26
+
27
+ .no_options {
28
+ padding: var(--spacing-4);
29
+ text-align: center;
30
+ color: var(--color-muted-foreground);
31
+ font-size: var(--font-size-sm);
32
+ }
33
+
34
+ /* Option button */
35
+ .option {
36
+ width: 100%;
37
+ /* The list scrolls; an option keeps its height. The button base sets a
38
+ min-height, which would otherwise let a long list squeeze each option
39
+ down to it, its text spilling over the next. */
40
+ flex-shrink: 0;
41
+ /* button base is min-width: fit-content - a long label must truncate
42
+ inside the row, never widen it past the menu */
43
+ min-width: 0;
44
+ /* button has justify-content: space-between */
45
+ justify-content: flex-start;
46
+ /* modifying padding of actual button variant for consistency */
47
+ padding: var(--spacing-3) var(--spacing-4);
48
+ border-radius: var(--radius-md);
49
+
50
+ /* > icon */
51
+
52
+ /* The label owns the row's slack space so trailing affordances
53
+ (.option_delete) always sit at the row's end - layout-driven, no
54
+ auto-margins (those tie with the Icon primitive's own margin reset
55
+ and the winner would depend on CSS bundle order). */
56
+ .option_label {
57
+ flex: 1 1 auto;
58
+ min-width: 0;
59
+ text-align: start;
60
+ }
61
+
62
+ /* With a description: two lines, so more room around them, and what
63
+ leads the row (a checkbox, an icon) sits at the label's line. */
64
+ &.option_rich {
65
+ padding-block: var(--spacing-5);
66
+ align-items: flex-start;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ .option_label {
73
+ max-width: 100% !important;
74
+ }
75
+
76
+ /* An option with a description: its label over a line saying what it does.
77
+ The pair takes the row's slack, as a label alone does, and the line wraps:
78
+ the button base keeps its text on one line. */
79
+ .option_text {
80
+ flex: 1 1 auto;
81
+ min-width: 0;
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: var(--spacing-2);
85
+ text-align: start;
86
+ }
87
+
88
+ .option_description {
89
+ color: var(--color-muted-foreground);
90
+ font-size: var(--font-size-xs);
91
+ font-weight: var(--font-weight-normal);
92
+ line-height: var(--line-height-md);
93
+ white-space: normal;
94
+ }
95
+
65
96
  /* Trailing per-option meta (option.meta). The growing .option_label pushes it
66
97
  to the row's end; consumers own the slot's visual styling. */
67
98
  .option_meta {
68
- flex-shrink: 0;
69
- display: inline-flex;
70
- align-items: center;
71
- gap: var(--spacing-2);
72
- margin-inline-start: var(--spacing-3);
73
- white-space: nowrap;
74
- }
75
-
76
- /* Trailing per-option delete affordance (option.onDelete). The growing
77
- .option_label pushes it to the row's end; muted until hovered, then
78
- error-toned. Click is stopPropagation'd in the component so it never
79
- selects the option. */
80
- .option_delete {
81
- flex-shrink: 0;
82
- --icon-color: var(--color-muted-foreground);
83
-
84
- &:hover {
85
- --icon-color: var(--color-error-foreground);
86
- }
87
- }
99
+ flex-shrink: 0;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: var(--spacing-2);
103
+ margin-inline-start: var(--spacing-3);
104
+ white-space: nowrap;
105
+ }
106
+
107
+ /* Trailing per-option delete affordance (option.onDelete). The growing
108
+ .option_label pushes it to the row's end; muted until hovered, then
109
+ error-toned. Click is stopPropagation'd in the component so it never
110
+ selects the option. */
111
+ .option_delete {
112
+ flex-shrink: 0;
113
+ --icon-color: var(--color-muted-foreground);
114
+
115
+ &:hover {
116
+ --icon-color: var(--color-error-foreground);
117
+ }
118
+ }
@@ -29,6 +29,10 @@ import type { BaseInputFieldProps } from "../common/types";
29
29
  export type CustomSelectOption = {
30
30
  value: string;
31
31
  label: string;
32
+ /** A line under the label in the menu, for an option its label alone does
33
+ * not explain. It wraps, never truncates, and search matches it too; once
34
+ * chosen, the select shows the label only. */
35
+ description?: string;
32
36
  icon?: ReactNode;
33
37
  /** Trailing slot after the label and before any delete affordance. The
34
38
  * component positions it; consumers own its visual styling. */
@@ -67,6 +71,10 @@ export type CustomSelectProps = Omit<
67
71
  allowCreate?: boolean;
68
72
  showDisplayText?: boolean;
69
73
  showDisplayIcon?: boolean;
74
+ /** What the select shows once something is chosen, in place of the chosen
75
+ * label or the count: "All images" for a choice that stands for every
76
+ * option. */
77
+ valueText?: string;
70
78
  onClear?: () => void;
71
79
 
72
80
  // Popover behavior defaults
@@ -111,6 +119,7 @@ export const CustomSelect = ({
111
119
  allowCreate = false,
112
120
  showDisplayIcon = true,
113
121
  showDisplayText = true,
122
+ valueText,
114
123
  disabled = false,
115
124
  readOnly = false,
116
125
  required = false,
@@ -188,7 +197,9 @@ export const CustomSelect = ({
188
197
  () =>
189
198
  searchable && searchTerm
190
199
  ? optionsWithSelected.filter((option) =>
191
- option.label.toLowerCase().includes(searchTerm.toLowerCase()),
200
+ [option.label, option.description ?? ""].some((text) =>
201
+ text.toLowerCase().includes(searchTerm.toLowerCase()),
202
+ ),
192
203
  )
193
204
  : optionsWithSelected,
194
205
  [optionsWithSelected, searchable, searchTerm],
@@ -225,9 +236,10 @@ export const CustomSelect = ({
225
236
  icon: selectedOption?.icon,
226
237
  displayText: !hasValue
227
238
  ? placeholder
228
- : isMultipleSelection
229
- ? `${selectedValues.length} selected`
230
- : singleLabel,
239
+ : (valueText ??
240
+ (isMultipleSelection
241
+ ? `${selectedValues.length} selected`
242
+ : singleLabel)),
231
243
  shouldShowIcon:
232
244
  showDisplayIcon && selectedOption?.icon && selectedValues.length === 1,
233
245
  appliedClassName: selectedOption?.applyToSelector
@@ -237,7 +249,7 @@ export const CustomSelect = ({
237
249
  ? selectedOption.optionStyle
238
250
  : undefined,
239
251
  };
240
- }, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon]);
252
+ }, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon, valueText]);
241
253
 
242
254
  const popoverOnClose = popover?.onClose;
243
255
  const handlePopoverClose = useCallback(() => {
@@ -304,6 +316,25 @@ export const CustomSelect = ({
304
316
  onChange?.(newValues);
305
317
  };
306
318
 
319
+ // Enter in the search box picks the first row: the Create row while one
320
+ // shows, else the first option found. A choice already made stays made.
321
+ const handleSearchKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
322
+ if (e.key !== "Enter") return;
323
+ e.preventDefault();
324
+ if (!trimmedTerm) return;
325
+ if (showCreateOption) {
326
+ handleSelect({ value: trimmedTerm, label: trimmedTerm });
327
+ } else {
328
+ const first = filteredOptions.find(
329
+ (option) =>
330
+ !option.disabled &&
331
+ !(multiple && selectedValues.includes(option.value)),
332
+ );
333
+ if (first) handleSelect(first);
334
+ }
335
+ setSearchTerm("");
336
+ };
337
+
307
338
  const handleClear = (e: React.MouseEvent) => {
308
339
  e.stopPropagation();
309
340
  if (!interactive) return;
@@ -406,6 +437,7 @@ export const CustomSelect = ({
406
437
  placeholder={searchPlaceholder}
407
438
  value={searchTerm}
408
439
  onChange={(e) => setSearchTerm(e.target.value)}
440
+ onKeyDown={handleSearchKey}
409
441
  onClick={(e) => e.stopPropagation()}
410
442
  showClear={true}
411
443
  autoFocus
@@ -445,7 +477,11 @@ export const CustomSelect = ({
445
477
  key={option.value}
446
478
  as="div"
447
479
  variant={option.optionButtonVariant || "ghost"}
448
- className={clsx(s.option, option.optionClassName)}
480
+ className={clsx(
481
+ s.option,
482
+ option.description && s.option_rich,
483
+ option.optionClassName,
484
+ )}
449
485
  style={option.optionStyle}
450
486
  isActive={selectedValues.includes(option.value)}
451
487
  disabled={disabled || readOnly || option.disabled}
@@ -460,11 +496,27 @@ export const CustomSelect = ({
460
496
  />
461
497
  )}
462
498
  {option.icon && option.icon}
463
- <span
464
- className={clsx(s.option_label, sCommon.truncate_text)}
465
- >
466
- {option.label}
467
- </span>
499
+ {option.description ? (
500
+ <span className={s.option_text}>
501
+ <span
502
+ className={clsx(
503
+ s.option_label,
504
+ sCommon.truncate_text,
505
+ )}
506
+ >
507
+ {option.label}
508
+ </span>
509
+ <span className={s.option_description}>
510
+ {option.description}
511
+ </span>
512
+ </span>
513
+ ) : (
514
+ <span
515
+ className={clsx(s.option_label, sCommon.truncate_text)}
516
+ >
517
+ {option.label}
518
+ </span>
519
+ )}
468
520
  {option.meta != null ? (
469
521
  <span className={s.option_meta}>{option.meta}</span>
470
522
  ) : null}