hlmnhkl-ui 0.15.0 → 0.17.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.17.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
 
@@ -19,7 +19,7 @@
19
19
  left: 0;
20
20
  z-index: -1;
21
21
  border-radius: var(--hl-radius-control);
22
- background: var(--hl-color-surface);
22
+ background: var(--hl-color-surface-raised);
23
23
  box-shadow: var(--hl-shadow-2), 0 0 0 var(--hl-line) var(--hl-color-border);
24
24
  }
25
25
 
@@ -26,10 +26,31 @@
26
26
  .rect { width: 100%; height: var(--hl-skeleton-block); border-radius: var(--hl-radius-md); }
27
27
  .circle { width: var(--hl-skeleton-avatar); height: var(--hl-skeleton-avatar); border-radius: var(--hl-radius-full); }
28
28
 
29
+ /* A sketch: the shapes as dashed pencil outlines, no fill. Text becomes dashed baselines. The lines
30
+ themselves fade gently instead of a shimmer, which would light up the empty box behind them. */
31
+ .sketch {
32
+ background: none;
33
+ border: var(--hl-line) dashed var(--hl-color-border-strong);
34
+ animation: sketching var(--hl-motion-shimmer) var(--hl-ease-in-out) infinite alternate;
35
+ animation-delay: calc(var(--i, 0) * var(--hl-motion-stagger) * 3);
36
+ }
37
+
38
+ .sketch::after { display: none; }
39
+
40
+ .sketch.text {
41
+ border-width: 0 0 var(--hl-line);
42
+ border-radius: 0;
43
+ }
44
+
45
+ @keyframes sketching {
46
+ from { border-color: var(--hl-color-border); }
47
+ }
48
+
29
49
  @keyframes shimmer {
30
50
  to { transform: translateX(100%); }
31
51
  }
32
52
 
33
53
  @media (prefers-reduced-motion: reduce) {
34
54
  .bone::after { animation: none; display: none; }
55
+ .sketch { animation: none; }
35
56
  }
@@ -11,24 +11,27 @@ export type SkeletonProps = {
11
11
  height?: CSSProperties['height']
12
12
  /** For text: number of lines. The last one is shorter, like real text. */
13
13
  lines?: number
14
+ /** Solid grey shapes, or a sketch: the same shapes as dashed outlines, like a pencil layout. */
15
+ variant?: 'solid' | 'sketch'
14
16
  /** Extra class for the root element. */
15
17
  className?: string
16
18
  }
17
19
 
18
20
  // Decorative only. Mark the loading container with aria-busy and a label instead.
19
- export function Skeleton({ shape = 'rect', width, height, lines = 1, className }: SkeletonProps) {
21
+ export function Skeleton({ shape = 'rect', width, height, lines = 1, variant = 'solid', className }: SkeletonProps) {
22
+ const look = variant === 'sketch' && styles.sketch
20
23
  if (shape === 'text') {
21
24
  return (
22
25
  <span className={cx(styles.lines, className)} style={{ width }} aria-hidden="true">
23
26
  {Array.from({ length: lines }, (_, i) => (
24
27
  <span
25
28
  key={i}
26
- className={cx(styles.bone, styles.text)}
29
+ className={cx(styles.bone, styles.text, look)}
27
30
  style={{ width: lines > 1 && i === lines - 1 ? '62%' : '100%', ['--i' as string]: i }}
28
31
  />
29
32
  ))}
30
33
  </span>
31
34
  )
32
35
  }
33
- return <span className={cx(styles.bone, styles[shape], className)} style={{ width, height }} aria-hidden="true" />
36
+ return <span className={cx(styles.bone, styles[shape], look, className)} style={{ width, height }} aria-hidden="true" />
34
37
  }
@@ -73,7 +73,7 @@
73
73
  margin-top: calc((var(--hl-track-h) - var(--thumb)) / 2);
74
74
  border: var(--hl-line-thick) solid var(--hl-color-accent);
75
75
  border-radius: var(--hl-radius-full);
76
- background: var(--hl-color-surface);
76
+ background: var(--hl-color-surface-raised);
77
77
  box-shadow: var(--hl-shadow-2);
78
78
  transition:
79
79
  transform var(--hl-motion-slow) var(--hl-ease-spring),
@@ -86,7 +86,7 @@
86
86
  box-sizing: border-box;
87
87
  border: var(--hl-line-thick) solid var(--hl-color-accent);
88
88
  border-radius: var(--hl-radius-full);
89
- background: var(--hl-color-surface);
89
+ background: var(--hl-color-surface-raised);
90
90
  box-shadow: var(--hl-shadow-2);
91
91
  transition:
92
92
  transform var(--hl-motion-slow) var(--hl-ease-spring),
@@ -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
@@ -23,6 +23,8 @@
23
23
  --hl-color-surface-sunken: var(--hl-gray-100);
24
24
  --hl-color-surface-hover: var(--hl-gray-150);
25
25
  --hl-color-surface-active: var(--hl-gray-200);
26
+ /* Something that sits on a track, such as a sliding pill or a slider thumb. */
27
+ --hl-color-surface-raised: var(--hl-color-surface);
26
28
  --hl-color-overlay: rgb(10 10 10 / 0.32);
27
29
  --hl-color-fg: var(--hl-gray-950);
28
30
  --hl-color-fg-muted: var(--hl-gray-600);
@@ -212,6 +214,7 @@
212
214
  --hl-color-surface-sunken: #121212;
213
215
  --hl-color-surface-hover: #262626;
214
216
  --hl-color-surface-active: #2e2e2e;
217
+ --hl-color-surface-raised: #2c2c2c;
215
218
  --hl-color-overlay: rgb(0 0 0 / 0.6);
216
219
  --hl-color-fg: #ececec;
217
220
  --hl-color-fg-muted: #ababab;
@@ -232,8 +235,9 @@
232
235
  --hl-color-danger-solid: #d63a2e;
233
236
  /* Darker on hover: a lighter red drops white text under 4.5:1 */
234
237
  --hl-color-danger-solid-hover: #b8302a;
235
- --hl-shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
236
- --hl-shadow-2: 0 1px 2px rgb(0 0 0 / 0.4), 0 4px 12px rgb(0 0 0 / 0.4);
238
+ /* Faint: in the dark, height shows as a lighter surface, not a deep shadow. */
239
+ --hl-shadow-1: 0 1px 2px rgb(0 0 0 / 0.2);
240
+ --hl-shadow-2: 0 1px 2px rgb(0 0 0 / 0.2), 0 4px 12px rgb(0 0 0 / 0.15);
237
241
  --hl-shadow-3: 0 2px 4px rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.5);
238
242
  --hl-shadow-4: 0 4px 8px rgb(0 0 0 / 0.4), 0 24px 64px rgb(0 0 0 / 0.6);
239
243
  color-scheme: dark;
@@ -248,6 +252,7 @@
248
252
  --hl-color-surface-sunken: #121212;
249
253
  --hl-color-surface-hover: #262626;
250
254
  --hl-color-surface-active: #2e2e2e;
255
+ --hl-color-surface-raised: #2c2c2c;
251
256
  --hl-color-overlay: rgb(0 0 0 / 0.6);
252
257
  --hl-color-fg: #ececec;
253
258
  --hl-color-fg-muted: #ababab;
@@ -268,8 +273,9 @@
268
273
  --hl-color-danger-solid: #d63a2e;
269
274
  /* Darker on hover: a lighter red drops white text under 4.5:1 */
270
275
  --hl-color-danger-solid-hover: #b8302a;
271
- --hl-shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
272
- --hl-shadow-2: 0 1px 2px rgb(0 0 0 / 0.4), 0 4px 12px rgb(0 0 0 / 0.4);
276
+ /* Faint: in the dark, height shows as a lighter surface, not a deep shadow. */
277
+ --hl-shadow-1: 0 1px 2px rgb(0 0 0 / 0.2);
278
+ --hl-shadow-2: 0 1px 2px rgb(0 0 0 / 0.2), 0 4px 12px rgb(0 0 0 / 0.15);
273
279
  --hl-shadow-3: 0 2px 4px rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.5);
274
280
  --hl-shadow-4: 0 4px 8px rgb(0 0 0 / 0.4), 0 24px 64px rgb(0 0 0 / 0.6);
275
281
  color-scheme: dark;
@@ -303,6 +309,8 @@
303
309
  --hl-day-mark: 4px;
304
310
  --hl-calendar-gap: 32px;
305
311
  --hl-wheel-rows: 5;
312
+ /* How far each row tilts per row away from the middle, so a wheel reads as a drum. */
313
+ --hl-wheel-tilt: 18deg;
306
314
  --hl-wheel-w: 64px;
307
315
 
308
316
  /* Smarter inputs */