@talelk/newui-organisms 1.0.0 → 2.0.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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  3. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  4. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  5. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  6. package/src/organisms/DataState/DataState.test.tsx +45 -0
  7. package/src/organisms/DataState/DataState.tsx +111 -0
  8. package/src/organisms/DataState/index.ts +6 -0
  9. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  10. package/src/organisms/DataTable/DataTable.tsx +426 -148
  11. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  12. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  13. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  14. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  15. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  16. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  17. package/src/organisms/Section/Section.test.tsx +41 -0
  18. package/src/organisms/Section/Section.tsx +108 -0
  19. package/src/organisms/Section/index.ts +1 -0
  20. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  21. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  22. package/src/organisms/SelectionBar/index.ts +5 -0
  23. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  24. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  25. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  26. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  27. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  28. package/src/organisms/index.ts +3 -0
  29. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -1,4 +1,4 @@
1
- import { cn, Icon, type IconProp } from '@talelk/newui-atoms'
1
+ import { cn, Icon, type IconProp, Skeleton } from '@talelk/newui-atoms'
2
2
  import { For, type JSX, Show } from 'solid-js'
3
3
  import { Dynamic } from 'solid-js/web'
4
4
 
@@ -17,6 +17,10 @@ export interface StatGridProps {
17
17
  stats: Stat[]
18
18
  /** Columns at the widest breakpoint. Defaults to the number of stats, capped. */
19
19
  columns?: 2 | 3 | 4
20
+ /** Tighter cards without the icon tile; for table-led screens. */
21
+ compact?: boolean
22
+ /** Replaces the cards with the shared skeleton geometry. */
23
+ loading?: boolean
20
24
  class?: string
21
25
  }
22
26
 
@@ -34,9 +38,16 @@ export interface StatGridProps {
34
38
  * to fail it.
35
39
  */
36
40
  const COLUMNS: Record<2 | 3 | 4, string> = {
37
- 2: 'sm:grid-cols-2',
38
- 3: 'sm:grid-cols-2 xl:grid-cols-3',
39
- 4: 'sm:grid-cols-2 xl:grid-cols-4',
41
+ 2: 'min-[360px]:grid-cols-2',
42
+ 3: 'min-[360px]:grid-cols-2 xl:grid-cols-3',
43
+ 4: 'min-[360px]:grid-cols-2 xl:grid-cols-4',
44
+ }
45
+
46
+ /** Compact table-led screens earn a four-up row on tablets. */
47
+ const COMPACT_COLUMNS: Record<2 | 3 | 4, string> = {
48
+ 2: 'min-[360px]:grid-cols-2',
49
+ 3: 'min-[360px]:grid-cols-2 md:grid-cols-3',
50
+ 4: 'min-[360px]:grid-cols-2 md:grid-cols-4',
40
51
  }
41
52
 
42
53
  export function StatGrid(props: StatGridProps): JSX.Element {
@@ -44,76 +55,92 @@ export function StatGrid(props: StatGridProps): JSX.Element {
44
55
  props.columns ?? (Math.min(4, Math.max(2, props.stats.length)) as 2 | 3 | 4)
45
56
 
46
57
  return (
47
- <div
48
- data-slot="stat-grid"
49
- class={`grid gap-card-gap ${COLUMNS[columns()]} ${props.class ?? ''}`}
58
+ <Show
59
+ when={!props.loading}
60
+ fallback={
61
+ <div
62
+ data-slot="stat-grid"
63
+ data-loading
64
+ class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
65
+ >
66
+ <For each={Array.from({ length: columns() })}>
67
+ {() => <Skeleton lines={3} class="h-24" />}
68
+ </For>
69
+ </div>
70
+ }
50
71
  >
51
- <For each={props.stats}>
52
- {(stat) => (
53
- // A stat that navigates is a button, not a div with a click handler:
54
- // it has to be reachable by keyboard and announced as interactive.
55
- <Dynamic
56
- component={stat.onClick ? 'button' : 'div'}
57
- {...(stat.onClick ? { type: 'button' as const } : {})}
58
- data-slot="stat"
59
- onClick={stat.onClick}
60
- class={cn(
61
- 'flex flex-col gap-control-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad text-start',
62
- stat.onClick &&
63
- 'cursor-pointer transition-[border-color,box-shadow] hover:border-outline-gray-3 hover:shadow-sm focus-visible:focus-ring motion-reduce:transition-none',
64
- )}
65
- >
66
- <div class="flex items-start gap-2">
67
- <Show when={stat.icon}>
68
- {(icon) => (
69
- <span class="grid size-8 shrink-0 place-items-center rounded-4 bg-surface-gray-2">
70
- <Icon icon={icon()} class="size-4 text-ink-gray-7" />
71
- </span>
72
- )}
73
- </Show>
74
- <span class="min-w-0 flex-1 text-sm-medium [overflow-wrap:anywhere] text-ink-gray-7">
75
- {stat.label}
72
+ <div
73
+ data-slot="stat-grid"
74
+ class={`grid gap-card-gap ${props.compact ? COMPACT_COLUMNS[columns()] : COLUMNS[columns()]} ${props.class ?? ''}`}
75
+ >
76
+ <For each={props.stats}>
77
+ {(stat) => (
78
+ // A stat that navigates is a button, not a div with a click handler:
79
+ // it has to be reachable by keyboard and announced as interactive.
80
+ <Dynamic
81
+ component={stat.onClick ? 'button' : 'div'}
82
+ {...(stat.onClick ? { type: 'button' as const } : {})}
83
+ data-slot="stat"
84
+ onClick={stat.onClick}
85
+ class={cn(
86
+ 'flex flex-col rounded-card border border-outline-gray-1 bg-surface-elevation-1 text-start',
87
+ props.compact ? 'gap-1 p-2.5' : 'gap-control-gap p-card-pad',
88
+ stat.onClick &&
89
+ 'cursor-pointer transition-[border-color,box-shadow] hover:border-outline-gray-3 hover:shadow-sm focus-visible:focus-ring motion-reduce:transition-none',
90
+ )}
91
+ >
92
+ <div class="flex items-start gap-2">
93
+ <Show when={props.compact ? undefined : stat.icon}>
94
+ {(icon) => (
95
+ <span class="grid size-8 shrink-0 place-items-center rounded-4 bg-surface-gray-2">
96
+ <Icon icon={icon()} class="size-4 text-ink-gray-7" />
97
+ </span>
98
+ )}
99
+ </Show>
100
+ <span class="min-w-0 flex-1 text-sm-medium [overflow-wrap:anywhere] text-ink-gray-7">
101
+ {stat.label}
102
+ </span>
103
+ <Show when={stat.delta}>
104
+ {(delta) => (
105
+ <span
106
+ class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs-medium tabular-nums"
107
+ classList={{
108
+ 'bg-surface-green-2 text-ink-green-8':
109
+ delta().direction !== 'flat' && isGood(delta()),
110
+ 'bg-surface-red-2 text-ink-red-8':
111
+ delta().direction !== 'flat' && !isGood(delta()),
112
+ 'bg-surface-gray-2 text-ink-gray-7':
113
+ delta().direction === 'flat',
114
+ }}
115
+ >
116
+ <Icon
117
+ icon={
118
+ delta().direction === 'up'
119
+ ? 'lucide-arrow-up'
120
+ : delta().direction === 'down'
121
+ ? 'lucide-arrow-down'
122
+ : 'lucide-minus'
123
+ }
124
+ class="size-3"
125
+ />
126
+ {delta().value}
127
+ </span>
128
+ )}
129
+ </Show>
130
+ </div>
131
+ <span class="text-metric tabular-nums [overflow-wrap:anywhere] text-ink-gray-9">
132
+ {stat.value}
76
133
  </span>
77
- <Show when={stat.delta}>
78
- {(delta) => (
79
- <span
80
- class="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs-medium tabular-nums"
81
- classList={{
82
- 'bg-surface-green-2 text-ink-green-8':
83
- delta().direction !== 'flat' && isGood(delta()),
84
- 'bg-surface-red-2 text-ink-red-8':
85
- delta().direction !== 'flat' && !isGood(delta()),
86
- 'bg-surface-gray-2 text-ink-gray-7':
87
- delta().direction === 'flat',
88
- }}
89
- >
90
- <Icon
91
- icon={
92
- delta().direction === 'up'
93
- ? 'lucide-arrow-up'
94
- : delta().direction === 'down'
95
- ? 'lucide-arrow-down'
96
- : 'lucide-minus'
97
- }
98
- class="size-3"
99
- />
100
- {delta().value}
101
- </span>
102
- )}
134
+ <Show when={stat.hint}>
135
+ <span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
136
+ {stat.hint}
137
+ </span>
103
138
  </Show>
104
- </div>
105
- <span class="text-metric tabular-nums [overflow-wrap:anywhere] text-ink-gray-9">
106
- {stat.value}
107
- </span>
108
- <Show when={stat.hint}>
109
- <span class="text-xs [overflow-wrap:anywhere] text-ink-gray-7">
110
- {stat.hint}
111
- </span>
112
- </Show>
113
- </Dynamic>
114
- )}
115
- </For>
116
- </div>
139
+ </Dynamic>
140
+ )}
141
+ </For>
142
+ </div>
143
+ </Show>
117
144
  )
118
145
  }
119
146
 
@@ -1,5 +1,6 @@
1
1
  import { createVirtualizer } from '@tanstack/solid-virtual'
2
- import { For, type JSX, untrack } from 'solid-js'
2
+ import { For, type JSX, Show, untrack } from 'solid-js'
3
+ import { DataState } from '../DataState'
3
4
 
4
5
  /**
5
6
  * A bounded DOM for a long list.
@@ -26,6 +27,14 @@ export interface VirtualListProps<T> {
26
27
  height?: string
27
28
  /** Rows rendered beyond the viewport. Default `6`. */
28
29
  overscan?: number
30
+ /** Replaces the list with the shared loading skeleton. */
31
+ loading?: boolean
32
+ /** Replaces the list with the shared error state. */
33
+ error?: JSX.Element
34
+ onRetry?: () => void
35
+ emptyTitle?: JSX.Element
36
+ emptyDescription?: JSX.Element
37
+ emptyAction?: JSX.Element
29
38
  /** One row's content; the `li` and its position belong to the list. */
30
39
  children: (item: T, index: number) => JSX.Element
31
40
  }
@@ -43,6 +52,15 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
43
52
  overscan: untrack(() => props.overscan) ?? 6,
44
53
  })
45
54
 
55
+ const status = () =>
56
+ props.loading
57
+ ? ('loading' as const)
58
+ : props.error
59
+ ? ('error' as const)
60
+ : props.items.length === 0
61
+ ? ('empty' as const)
62
+ : ('ready' as const)
63
+
46
64
  return (
47
65
  <section
48
66
  ref={viewport}
@@ -52,28 +70,44 @@ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
52
70
  class="relative overflow-auto rounded-3 focus-visible:focus-ring"
53
71
  data-slot="virtual-list"
54
72
  >
55
- <ul
56
- class="relative m-0 list-none p-0"
57
- style={{ height: `${virtualizer.getTotalSize()}px` }}
73
+ <Show
74
+ when={status() === 'ready'}
75
+ fallback={
76
+ <DataState
77
+ status={status()}
78
+ skeleton="rows"
79
+ rows={5}
80
+ title={props.emptyTitle}
81
+ description={props.emptyDescription}
82
+ action={props.emptyAction}
83
+ error={props.error}
84
+ onRetry={props.onRetry}
85
+ />
86
+ }
58
87
  >
59
- <For each={virtualizer.getVirtualItems()}>
60
- {(row) => (
61
- <li
62
- class="absolute inset-x-0 top-0"
63
- style={{
64
- height: `${props.itemSize}px`,
65
- transform: `translateY(${row.start}px)`,
66
- }}
67
- aria-posinset={row.index + 1}
68
- aria-setsize={props.items.length}
69
- >
70
- {/* The virtualiser's count is the array's length, so the index
71
- is a valid key for the mapped row. */}
72
- {props.children(props.items[row.index] as T, row.index)}
73
- </li>
74
- )}
75
- </For>
76
- </ul>
88
+ <ul
89
+ class="relative m-0 list-none p-0"
90
+ style={{ height: `${virtualizer.getTotalSize()}px` }}
91
+ >
92
+ <For each={virtualizer.getVirtualItems()}>
93
+ {(row) => (
94
+ <li
95
+ class="absolute inset-x-0 top-0"
96
+ style={{
97
+ height: `${props.itemSize}px`,
98
+ transform: `translateY(${row.start}px)`,
99
+ }}
100
+ aria-posinset={row.index + 1}
101
+ aria-setsize={props.items.length}
102
+ >
103
+ {/* The virtualiser's count is the array's length, so the index
104
+ is a valid key for the mapped row. */}
105
+ {props.children(props.items[row.index] as T, row.index)}
106
+ </li>
107
+ )}
108
+ </For>
109
+ </ul>
110
+ </Show>
77
111
  </section>
78
112
  )
79
113
  }
@@ -1,12 +1,15 @@
1
1
  export * from './AppSidebar'
2
2
  export * from './AuthCard'
3
3
  export * from './CommandPalette'
4
+ export * from './DataState'
4
5
  export * from './DataTable'
5
6
  export * from './DetailHeader'
6
7
  export * from './FilterBar'
7
8
  export * from './KanbanBoard'
8
9
  export * from './MobileNav'
9
10
  export * from './PageToolbar'
11
+ export * from './Section'
12
+ export * from './SelectionBar'
10
13
  export * from './SettingsDialog'
11
14
  export * from './Sidebar'
12
15
  export * from './StatGrid'