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 +1 -1
- package/package.json +1 -1
- package/scripts/pro-components.json +1 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -2
- package/src/components/CopyButton/CopyButton.module.css +3 -2
- package/src/components/PasswordInput/PasswordInput.module.css +3 -2
- package/src/components/Table/Table.module.css +32 -3
- package/src/components/Table/Table.tsx +74 -35
- package/src/components/Tabs/Tabs.module.css +6 -0
- package/src/tokens.css +2 -0
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
|
-
|
|
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
|
@@ -71,8 +71,9 @@
|
|
|
71
71
|
|
|
72
72
|
.tick,
|
|
73
73
|
.dash {
|
|
74
|
-
|
|
75
|
-
stroke-
|
|
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
|
-
|
|
66
|
-
stroke-
|
|
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
|
-
|
|
59
|
-
stroke-
|
|
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
|
-
|
|
197
|
-
stroke-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
169
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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