hlmnhkl-ui 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -49,7 +49,7 @@ Using Next.js or React Router? The [installation guide](https://ui.hlmnhkl.my/in
49
49
 
50
50
  ## Pro
51
51
 
52
- 53 more components (such as Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
52
+ 54 more components (such as Calendar, Chart, KanbanBoard, Combobox and Route), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
53
53
 
54
54
  ## Licence
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
@@ -40,6 +40,7 @@
40
40
  "RelativeTime",
41
41
  "ResizablePanels",
42
42
  "RichTextEditor",
43
+ "Route",
43
44
  "Sidebar",
44
45
  "Sparkline",
45
46
  "Stat",
@@ -71,8 +71,9 @@
71
71
 
72
72
  .tick,
73
73
  .dash {
74
- stroke-dasharray: 1;
75
- stroke-dashoffset: 1;
74
+ /* Hidden just before the path starts. A zero-length dash would leave its round ends as a dot in Safari. */
75
+ stroke-dasharray: 1 2;
76
+ stroke-dashoffset: 1.01;
76
77
  transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-out);
77
78
  }
78
79
 
@@ -62,8 +62,9 @@
62
62
  stroke-width: 2.25;
63
63
  stroke-linecap: round;
64
64
  stroke-linejoin: round;
65
- stroke-dasharray: 1;
66
- stroke-dashoffset: 1;
65
+ /* Hidden just before the path starts. A zero-length dash would leave its round ends as a dot in Safari. */
66
+ stroke-dasharray: 1 2;
67
+ stroke-dashoffset: 1.01;
67
68
  transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-in);
68
69
  }
69
70
 
@@ -55,8 +55,9 @@
55
55
  .eye[data-open] .pupil { transform: scale(0.8); }
56
56
 
57
57
  .slash {
58
- stroke-dasharray: 1;
59
- stroke-dashoffset: 1;
58
+ /* Hidden just before the path starts. A zero-length dash would leave its round ends as a dot in Safari. */
59
+ stroke-dasharray: 1 2;
60
+ stroke-dashoffset: 1.01;
60
61
  transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-out);
61
62
  }
62
63
 
@@ -131,6 +131,33 @@
131
131
  text-align: left;
132
132
  }
133
133
 
134
+ /* A group's header: its name and how many rows it holds, on a quiet band that opens a gap above it,
135
+ so the groups read as pieces the table split into. */
136
+ .groupHead {
137
+ padding: var(--hl-space-6) var(--hl-space-5) var(--hl-space-2);
138
+ border-top: var(--hl-line) solid var(--hl-color-border);
139
+ background: var(--hl-color-surface-sunken);
140
+ font-size: var(--hl-text-sm);
141
+ font-weight: var(--hl-weight-semibold);
142
+ text-align: left;
143
+ }
144
+
145
+ .group:first-of-type .groupHead { padding-top: var(--hl-space-3); }
146
+
147
+ .groupCount {
148
+ margin-left: var(--hl-space-3);
149
+ font-size: var(--hl-text-xs);
150
+ font-weight: var(--hl-weight-regular);
151
+ color: var(--hl-color-fg-muted);
152
+ }
153
+
154
+ /* A new group's header settles in while its rows glide into place below it. */
155
+ .groupRow { animation: groupIn var(--hl-motion-slow) var(--hl-ease-out) both; }
156
+
157
+ @keyframes groupIn {
158
+ from { opacity: 0; transform: translateY(calc(var(--hl-shift-sm) * -1)); }
159
+ }
160
+
134
161
  .empty {
135
162
  padding: var(--hl-space-9) var(--hl-space-5);
136
163
  border-top: var(--hl-line) solid var(--hl-color-border);
@@ -193,8 +220,9 @@
193
220
 
194
221
  .tick,
195
222
  .dash {
196
- stroke-dasharray: 1;
197
- stroke-dashoffset: 1;
223
+ /* Hidden just before the path starts. A zero-length dash would leave its round ends as a dot in Safari. */
224
+ stroke-dasharray: 1 2;
225
+ stroke-dashoffset: 1.01;
198
226
  transition: stroke-dashoffset var(--hl-motion-base) var(--hl-ease-out);
199
227
  }
200
228
 
@@ -215,7 +243,8 @@
215
243
  [data-layout="stack"] .table,
216
244
  [data-layout="stack"] .table tbody,
217
245
  [data-layout="stack"] .table tr,
218
- [data-layout="stack"] .table td { display: block; }
246
+ [data-layout="stack"] .table td,
247
+ [data-layout="stack"] .table .groupHead { display: block; }
219
248
 
220
249
  [data-layout="stack"] .head { display: block; }
221
250
 
@@ -31,6 +31,8 @@ export type TableLabels = {
31
31
  sortedBy: (column: string, direction: TableSort['direction']) => string
32
32
  /** Spoken when a third press turns the sort off. */
33
33
  unsorted: (column: string) => string
34
+ /** The row count beside each group's name. */
35
+ groupCount: (count: number) => string
34
36
  }
35
37
 
36
38
  const defaultLabels: TableLabels = {
@@ -38,6 +40,7 @@ const defaultLabels: TableLabels = {
38
40
  selectRow: (name) => `Select ${name}`,
39
41
  sortedBy: (column, direction) => `Sorted by ${column}, ${direction}`,
40
42
  unsorted: (column) => `No longer sorted by ${column}`,
43
+ groupCount: (count) => `${count} ${count === 1 ? 'row' : 'rows'}`,
41
44
  }
42
45
 
43
46
  export type TableProps<T> = {
@@ -57,6 +60,9 @@ export type TableProps<T> = {
57
60
  defaultSort?: TableSort | null
58
61
  /** Called with the new sort when a header is pressed: ascending, descending, then null. */
59
62
  onSortChange?: (sort: TableSort | null) => void
63
+ /** Gathers rows under a header per group, such as each row's status. Groups keep the order they first
64
+ * appear in; the sort applies inside each group. Rows glide to their group when it changes. */
65
+ groupBy?: (row: T) => string
60
66
  /** Adds a checkbox column. */
61
67
  selectable?: boolean
62
68
  /** Ids of the selected rows, when you control it. */
@@ -109,6 +115,7 @@ export function Table<T>({
109
115
  sort: sortProp,
110
116
  defaultSort = null,
111
117
  onSortChange,
118
+ groupBy,
112
119
  selectable,
113
120
  selected: selectedProp,
114
121
  defaultSelected = [],
@@ -143,8 +150,10 @@ export function Table<T>({
143
150
  const [sort, setSort] = useControllable<TableSort | null>(sortProp, defaultSort, onSortChange)
144
151
  const [selected, setSelected] = useControllable(selectedProp, defaultSelected, onSelectedChange)
145
152
  const [announce, setAnnounce] = useState('')
146
- const bodyRef = useRef<HTMLTableSectionElement>(null)
153
+ const tableRef = useRef<HTMLTableElement>(null)
147
154
  const lastTops = useRef(new Map<string, number>())
155
+ const lastShape = useRef('')
156
+ const groupOrder = useRef<string[]>([])
148
157
 
149
158
  const sorted = useMemo(() => {
150
159
  if (!sort) return rows
@@ -161,16 +170,26 @@ export function Table<T>({
161
170
  return out
162
171
  }, [rows, columns, sort])
163
172
 
164
- const order = sorted.map(getRowId).join('|')
173
+ // Groups take the order they first appear in, and keep it: a sort, or a row moving to another group,
174
+ // never shuffles the groups themselves. A group seen for the first time joins at the end.
175
+ let groups: Array<{ key: string; rows: T[] }> | null = null
176
+ if (groupBy) {
177
+ for (const key of new Set(rows.map(groupBy))) if (!groupOrder.current.includes(key)) groupOrder.current.push(key)
178
+ groups = groupOrder.current.map((key) => ({ key, rows: sorted.filter((r) => groupBy(r) === key) })).filter((g) => g.rows.length)
179
+ }
180
+ const shape = groups ? groups.map((g) => g.key).join('|') : ''
181
+ const order = groups ? groups.map((g) => `${g.key}:${g.rows.map(getRowId).join(',')}`).join('|') : sorted.map(getRowId).join('|')
165
182
 
166
- // FLIP: rows glide to their new place after a sort
183
+ // FLIP: rows glide to their new place after a sort. When the groups themselves change, they snap
184
+ // there with the spring, so the table visibly splits apart and closes up again.
167
185
  useLayoutEffect(() => {
168
- const body = bodyRef.current
169
- if (!body) return
186
+ const table = tableRef.current
187
+ if (!table) return
170
188
  const duration = motionMs('slow')
171
- const ease = easing('out')
189
+ const ease = easing(shape !== lastShape.current ? 'spring' : 'out')
190
+ lastShape.current = shape
172
191
  const now = new Map<string, number>()
173
- body.querySelectorAll<HTMLElement>('[data-row-id]').forEach((tr) => {
192
+ table.querySelectorAll<HTMLElement>('tbody [data-row-id]').forEach((tr) => {
174
193
  const rid = tr.dataset.rowId!
175
194
  const top = tr.offsetTop
176
195
  now.set(rid, top)
@@ -204,11 +223,35 @@ export function Table<T>({
204
223
  const scrollable = Boolean(maxHeight) || more.start || more.end
205
224
  const nameOf = (row: T) => (rowLabel ? rowLabel(row) : String(read(row, columns[0]?.key ?? '') ?? getRowId(row)))
206
225
 
226
+ const colCount = columns.length + (selectable ? 1 : 0)
227
+ // Keeps the entrance stagger going across groups.
228
+ let rowIndex = 0
229
+ const renderRow = (row: T) => {
230
+ const ri = rowIndex++
231
+ const rid = getRowId(row)
232
+ const on = selectedSet.has(rid)
233
+ return (
234
+ <tr key={rid} role="row" data-row-id={rid} className={styles.row} data-selected={on || undefined} style={{ ['--i' as string]: Math.min(ri, 12) }}>
235
+ {selectable && (
236
+ <td role="cell" className={styles.checkCell}>
237
+ <Check checked={on} label={labels.selectRow(nameOf(row))} onChange={(v) => setSelected(v ? [...selected, rid] : selected.filter((s) => s !== rid))} />
238
+ </td>
239
+ )}
240
+ {columns.map((c) => (
241
+ <td key={c.key} role="cell" className={styles.td} data-align={c.align ?? 'start'} data-label={labelOf(c)}>
242
+ {/* One wrapper, so the card layout always gets a label and a value */}
243
+ <span>{c.cell ? c.cell(row) : String(read(row, c.key) ?? '')}</span>
244
+ </td>
245
+ ))}
246
+ </tr>
247
+ )
248
+ }
249
+
207
250
  return (
208
251
  <div className={cx(styles.root, className)} data-layout={layout} data-more-start={more.start || undefined} data-more-end={more.end || undefined}>
209
252
  <div ref={scrollRef} onScroll={measure} className={styles.scroll} style={{ maxHeight }} tabIndex={scrollable ? 0 : undefined} role={scrollable ? 'region' : undefined} aria-labelledby={scrollable ? `${id}-cap` : undefined}>
210
253
  {/* Explicit roles keep table semantics when rows restyle as cards (layout="stack") */}
211
- <table role="table" className={styles.table} aria-labelledby={`${id}-cap`}>
254
+ <table ref={tableRef} role="table" className={styles.table} aria-labelledby={`${id}-cap`}>
212
255
  <caption id={`${id}-cap`} className={cx(styles.caption, hideCaption && styles.srOnly)}>
213
256
  {caption}
214
257
  </caption>
@@ -253,34 +296,30 @@ export function Table<T>({
253
296
  })}
254
297
  </tr>
255
298
  </thead>
256
- <tbody role="rowgroup" ref={bodyRef}>
257
- {sorted.map((row, ri) => {
258
- const rid = getRowId(row)
259
- const on = selectedSet.has(rid)
260
- return (
261
- <tr key={rid} role="row" data-row-id={rid} className={styles.row} data-selected={on || undefined} style={{ ['--i' as string]: Math.min(ri, 12) }}>
262
- {selectable && (
263
- <td role="cell" className={styles.checkCell}>
264
- <Check checked={on} label={labels.selectRow(nameOf(row))} onChange={(v) => setSelected(v ? [...selected, rid] : selected.filter((s) => s !== rid))} />
265
- </td>
266
- )}
267
- {columns.map((c) => (
268
- <td key={c.key} role="cell" className={styles.td} data-align={c.align ?? 'start'} data-label={labelOf(c)}>
269
- {/* One wrapper, so the card layout always gets a label and a value */}
270
- <span>{c.cell ? c.cell(row) : String(read(row, c.key) ?? '')}</span>
271
- </td>
272
- ))}
299
+ {groups && groups.length > 0 ? (
300
+ groups.map((g) => (
301
+ <tbody key={g.key} role="rowgroup" className={styles.group}>
302
+ <tr role="row" data-row-id={`group:${g.key}`} className={styles.groupRow}>
303
+ <th role="rowheader" scope="rowgroup" colSpan={colCount} className={styles.groupHead}>
304
+ <span>{g.key}</span>{' '}
305
+ <span className={styles.groupCount}>{labels.groupCount(g.rows.length)}</span>
306
+ </th>
307
+ </tr>
308
+ {g.rows.map(renderRow)}
309
+ </tbody>
310
+ ))
311
+ ) : (
312
+ <tbody role="rowgroup">
313
+ {sorted.map(renderRow)}
314
+ {sorted.length === 0 && (
315
+ <tr role="row">
316
+ <td role="cell" colSpan={colCount} className={styles.empty}>
317
+ {emptyText}
318
+ </td>
273
319
  </tr>
274
- )
275
- })}
276
- {sorted.length === 0 && (
277
- <tr role="row">
278
- <td role="cell" colSpan={columns.length + (selectable ? 1 : 0)} className={styles.empty}>
279
- {emptyText}
280
- </td>
281
- </tr>
282
- )}
283
- </tbody>
320
+ )}
321
+ </tbody>
322
+ )}
284
323
  </table>
285
324
  </div>
286
325
  <p className={styles.srOnly} role="status">{announce}</p>
@@ -22,6 +22,7 @@
22
22
 
23
23
  .tab {
24
24
  position: relative;
25
+ min-width: var(--hl-hit-min);
25
26
  min-height: var(--hl-hit-min);
26
27
  padding: 0 var(--hl-space-4);
27
28
  border: 0;
@@ -36,6 +37,11 @@
36
37
  transition: color var(--hl-motion-base) var(--hl-ease-out), background-color var(--hl-motion-fast) var(--hl-ease-out);
37
38
  }
38
39
 
40
+ /* Narrow screens: a little less room around each tab, so a short row fits instead of scrolling. */
41
+ @media (max-width: 479px) {
42
+ .tab { padding: 0 var(--hl-space-3); }
43
+ }
44
+
39
45
  @media (hover: hover) {
40
46
  .tab:hover:not(:disabled) { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
41
47
  }
package/src/tokens.css CHANGED
@@ -303,6 +303,8 @@
303
303
  --hl-day-mark: 4px;
304
304
  --hl-calendar-gap: 32px;
305
305
  --hl-wheel-rows: 5;
306
+ /* How far each row tilts per row away from the middle, so a wheel reads as a drum. */
307
+ --hl-wheel-tilt: 18deg;
306
308
  --hl-wheel-w: 64px;
307
309
 
308
310
  /* Smarter inputs */