@pgcorp/ui-kit 0.4.0 → 0.5.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
@@ -149,6 +149,27 @@ Utility columns support `headerPresentation: 'assistive'`, `width: '2xs'`, and
149
149
  `allow-custom-value`; `SPageHeader` combines `appearance="bare"` with
150
150
  `title-presentation="assistive"` for an accessible zero-chrome heading.
151
151
 
152
+ Составная сортировка использует controlled `v-model:sorts`: consumer задаёт
153
+ `sort-mode="multiple"` и передаёт ordered `STableSort[]`. Клик по
154
+ заголовку добавляет критерий, меняет направление или удаляет его; номер возле
155
+ заголовка показывает приоритет. Таблица не переставляет строки самостоятельно.
156
+
157
+ Multi-column sorting uses controlled `v-model:sorts`: the consumer sets
158
+ `sort-mode="multiple"` and provides an ordered `STableSort[]`. Header activation
159
+ adds a descriptor, changes its direction, or removes it; the visible number is
160
+ the priority. The table never reorders rows on its own.
161
+
162
+ ```vue
163
+ <STable
164
+ v-model:sorts="sorts"
165
+ :columns="columns"
166
+ :data="sortedRows"
167
+ :get-row-key="(row) => row.id"
168
+ sort-mode="multiple"
169
+ aria-label="Позиции прайс-листа"
170
+ />
171
+ ```
172
+
152
173
  ## Темы и стили / Themes and styles
153
174
 
154
175
  Для обычного приложения подключайте полный style entrypoint один раз:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgcorp/ui-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -58,6 +58,8 @@
58
58
  width="full"
59
59
  :content-align="column.align === 'right' ? 'end' : column.align === 'center' ? 'center' : 'start'"
60
60
  :trailing-icon="sortIcon(column)"
61
+ :badge="sortPriority(column) ?? undefined"
62
+ badge-severity="primary"
61
63
  @click="requestSort(column)"
62
64
  >
63
65
  <span
@@ -193,6 +195,7 @@ import type {
193
195
  STableRowState,
194
196
  STableSelectionMode,
195
197
  STableSort,
198
+ STableSortMode,
196
199
  STableSurface,
197
200
  } from './table'
198
201
 
@@ -209,6 +212,7 @@ export type {
209
212
  STableSelectionMode,
210
213
  STableSort,
211
214
  STableSortDirection,
215
+ STableSortMode,
212
216
  STableSurface,
213
217
  } from './table'
214
218
 
@@ -232,7 +236,9 @@ export interface STableProps<
232
236
  expandedRowKeys?: readonly RowKey[]
233
237
  isRowExpandable?: (row: Row, index: number) => boolean
234
238
  getRowLabel?: (row: Row, index: number) => string
239
+ sortMode?: STableSortMode
235
240
  sort?: STableSort<Field> | null
241
+ sorts?: readonly STableSort<Field>[]
236
242
  maxHeight?: ScrollableViewportMaxBlockSize
237
243
  density?: STableDensity
238
244
  surface?: STableSurface
@@ -282,7 +288,9 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
282
288
  expandedRowKeys: () => [],
283
289
  isRowExpandable: undefined,
284
290
  getRowLabel: undefined,
291
+ sortMode: 'single',
285
292
  sort: null,
293
+ sorts: () => [],
286
294
  maxHeight: 'none',
287
295
  density: 'default',
288
296
  surface: 'bordered',
@@ -301,6 +309,8 @@ const emit = defineEmits<{
301
309
  'row-expand': [row: Row, index: number, expanded: boolean]
302
310
  'update:sort': [value: STableSort<Field> | null]
303
311
  sort: [value: STableSort<Field> | null]
312
+ 'update:sorts': [value: readonly STableSort<Field>[]]
313
+ 'multi-sort': [value: readonly STableSort<Field>[]]
304
314
  }>()
305
315
  const slots = useSlots()
306
316
 
@@ -313,6 +323,8 @@ const TABLE_CELL_STATES = ['default', 'pending'] as const
313
323
  const TABLE_COLUMN_CONTENT = ['field', 'value', 'slot'] as const
314
324
  const TABLE_HEADER_PRESENTATIONS = ['visible', 'assistive'] as const
315
325
  const TABLE_SELECTION_MODES = ['none', 'multiple'] as const
326
+ const TABLE_SORT_MODES = ['single', 'multiple'] as const
327
+ const TABLE_SORT_DIRECTIONS = ['ascending', 'descending'] as const
316
328
 
317
329
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
318
330
  useInteractiveLeafRegistration({ owner: 'STable' })
@@ -389,6 +401,12 @@ const validatedSelectionMode = computed(() => validateExactString(
389
401
  props.selectionMode,
390
402
  TABLE_SELECTION_MODES,
391
403
  ))
404
+ const validatedSortMode = computed(() => validateExactString(
405
+ 'STable',
406
+ 'sortMode',
407
+ props.sortMode,
408
+ TABLE_SORT_MODES,
409
+ ))
392
410
 
393
411
  const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
394
412
  const fields = new Set<string>()
@@ -454,9 +472,15 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
454
472
  return props.columns
455
473
  })
456
474
 
457
- const validatedSort = computed<STableSort<Field> | null>(() => {
458
- if (props.sort === null || props.sort === undefined) return null
459
- const key = validateNonEmptyString('STable', 'sort.key', props.sort.key) as Field
475
+ function validateSortEntry(value: unknown, coordinate: string): STableSort<Field> {
476
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
477
+ throw new TypeError(
478
+ `STable: ${coordinate} должен быть STableSort object. `
479
+ + `/ STable: ${coordinate} must be an STableSort object.`,
480
+ )
481
+ }
482
+ const entry = value as Record<string, unknown>
483
+ const key = validateNonEmptyString('STable', `${coordinate}.key`, entry.key) as Field
460
484
  const column = validatedColumns.value.find((candidate) => candidate.field === key)
461
485
  if (!column?.sortable) {
462
486
  throw new TypeError(`STable: sort key ${JSON.stringify(key)} должен ссылаться на sortable column. / STable: sort key ${JSON.stringify(key)} must reference a sortable column.`)
@@ -465,25 +489,100 @@ const validatedSort = computed<STableSort<Field> | null>(() => {
465
489
  key,
466
490
  direction: validateExactString(
467
491
  'STable',
468
- 'sort.direction',
469
- props.sort.direction,
470
- ['ascending', 'descending'] as const,
492
+ `${coordinate}.direction`,
493
+ entry.direction,
494
+ TABLE_SORT_DIRECTIONS,
471
495
  ),
472
496
  }
497
+ }
498
+
499
+ const validatedSingleSort = computed<STableSort<Field> | null>(() => {
500
+ if (validatedSortMode.value === 'multiple') {
501
+ if (props.sort !== null && props.sort !== undefined) {
502
+ throw new TypeError(
503
+ 'STable: sortMode="multiple" несовместим с sort; используйте v-model:sorts. '
504
+ + '/ STable: sortMode="multiple" is incompatible with sort; use v-model:sorts.',
505
+ )
506
+ }
507
+ return null
508
+ }
509
+ if (props.sort === null || props.sort === undefined) return null
510
+ return validateSortEntry(props.sort, 'sort')
511
+ })
512
+
513
+ const validatedMultipleSort = computed<readonly STableSort<Field>[]>(() => {
514
+ const sorts: unknown = props.sorts
515
+ if (!Array.isArray(sorts)) {
516
+ throw new TypeError(
517
+ 'STable: sorts должен быть ordered STableSort array. '
518
+ + '/ STable: sorts must be an ordered STableSort array.',
519
+ )
520
+ }
521
+ if (validatedSortMode.value === 'single') {
522
+ if (sorts.length > 0) {
523
+ throw new TypeError(
524
+ 'STable: sortMode="single" несовместим с непустым sorts; используйте v-model:sort. '
525
+ + '/ STable: sortMode="single" is incompatible with non-empty sorts; use v-model:sort.',
526
+ )
527
+ }
528
+ return []
529
+ }
530
+ const keys = new Set<Field>()
531
+ return sorts.map((entry, index) => {
532
+ const validated = validateSortEntry(entry, `sorts[${index}]`)
533
+ if (keys.has(validated.key)) {
534
+ throw new TypeError(
535
+ `STable: sorts содержит duplicate key ${JSON.stringify(validated.key)}. `
536
+ + `/ STable: sorts contains duplicate key ${JSON.stringify(validated.key)}.`,
537
+ )
538
+ }
539
+ keys.add(validated.key)
540
+ return validated
541
+ })
473
542
  })
474
543
 
544
+ const activeSorts = computed<readonly STableSort<Field>[]>(() => {
545
+ const singleSort = validatedSingleSort.value
546
+ const multipleSort = validatedMultipleSort.value
547
+ if (validatedSortMode.value === 'multiple') return multipleSort
548
+ return singleSort === null ? [] : [singleSort]
549
+ })
550
+
551
+ function sortIndex(column: STableColumn<Row, Field>): number {
552
+ return activeSorts.value.findIndex((candidate) => candidate.key === column.field)
553
+ }
554
+
555
+ function sortPriority(column: STableColumn<Row, Field>): number | null {
556
+ if (validatedSortMode.value !== 'multiple') return null
557
+ const index = sortIndex(column)
558
+ return index === -1 ? null : index + 1
559
+ }
560
+
475
561
  const columnAriaSort = (column: STableColumn<Row, Field>): 'none' | 'ascending' | 'descending' | undefined => {
476
562
  if (!column.sortable) return undefined
477
- return validatedSort.value?.key === column.field ? validatedSort.value.direction : 'none'
563
+ if (validatedSortMode.value === 'multiple') {
564
+ const primary = activeSorts.value[0]
565
+ return primary?.key === column.field ? primary.direction : undefined
566
+ }
567
+ const current = activeSorts.value[0]
568
+ return current?.key === column.field ? current.direction : 'none'
478
569
  }
479
570
 
480
571
  const sortIcon = (column: STableColumn<Row, Field>) => {
481
- const direction = columnAriaSort(column)
572
+ const direction = activeSorts.value.find((candidate) => candidate.key === column.field)?.direction
482
573
  return direction === 'ascending' ? ChevronUp : direction === 'descending' ? ChevronDown : ChevronsUpDown
483
574
  }
484
575
 
485
576
  const sortActionLabel = (column: STableColumn<Row, Field>): string => {
486
- const direction = columnAriaSort(column)
577
+ const index = sortIndex(column)
578
+ const current = index === -1 ? undefined : activeSorts.value[index]
579
+ if (validatedSortMode.value === 'multiple') {
580
+ if (!current) return `${column.header}: добавить в сортировку по возрастанию`
581
+ const state = current.direction === 'ascending' ? 'по возрастанию' : 'по убыванию'
582
+ const action = current.direction === 'ascending' ? 'сортировать по убыванию' : 'убрать из сортировки'
583
+ return `${column.header}: приоритет ${index + 1} из ${activeSorts.value.length}, ${state}; ${action}`
584
+ }
585
+ const direction = current?.direction
487
586
  if (direction === 'ascending') return `${column.header}: сортировать по убыванию`
488
587
  if (direction === 'descending') return `${column.header}: сбросить сортировку`
489
588
  return `${column.header}: сортировать по возрастанию`
@@ -491,15 +590,29 @@ const sortActionLabel = (column: STableColumn<Row, Field>): string => {
491
590
 
492
591
  function requestSort(column: STableColumn<Row, Field>): void {
493
592
  if (!column.sortable) return
494
- const current = validatedSort.value
495
593
  const key = column.field as Field
496
- const next: STableSort<Field> | null = current?.key !== key
497
- ? { key, direction: 'ascending' }
498
- : current.direction === 'ascending'
499
- ? { key, direction: 'descending' }
500
- : null
501
- emit('update:sort', next)
502
- emit('sort', next)
594
+ if (validatedSortMode.value === 'multiple') {
595
+ const current = activeSorts.value
596
+ const index = sortIndex(column)
597
+ let next: readonly STableSort<Field>[]
598
+ if (index === -1) next = [...current, { key, direction: 'ascending' }]
599
+ else if (current[index]!.direction === 'ascending') {
600
+ next = current.map((entry, candidateIndex) => candidateIndex === index
601
+ ? { key: entry.key, direction: 'descending' }
602
+ : entry)
603
+ } else next = current.filter((_entry, candidateIndex) => candidateIndex !== index)
604
+ emit('update:sorts', next)
605
+ emit('multi-sort', next)
606
+ } else {
607
+ const current = activeSorts.value[0]
608
+ const next: STableSort<Field> | null = current?.key !== key
609
+ ? { key, direction: 'ascending' }
610
+ : current.direction === 'ascending'
611
+ ? { key, direction: 'descending' }
612
+ : null
613
+ emit('update:sort', next)
614
+ emit('sort', next)
615
+ }
503
616
  }
504
617
 
505
618
  function describeRowKey(key: STableRowKey): string {
@@ -671,7 +784,7 @@ const rowExpandableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
671
784
 
672
785
  const validatedRows = computed<readonly Row[]>(() => {
673
786
  const columns = validatedColumns.value
674
- void validatedSort.value
787
+ void activeSorts.value
675
788
  void resolvedRowKeys.value
676
789
  void validatedSelectedRowKey.value
677
790
  void validatedSelectedRowKeys.value
@@ -14,6 +14,7 @@ export type STableColumnContent = 'field' | 'value' | 'slot'
14
14
  export type STableColumnHeaderPresentation = 'visible' | 'assistive'
15
15
  export type STableSelectionMode = 'none' | 'multiple'
16
16
  export type STableSortDirection = 'ascending' | 'descending'
17
+ export type STableSortMode = 'single' | 'multiple'
17
18
 
18
19
  /** Controlled sort state; consumer owns data ordering. / Управляемое состояние сортировки; порядок данных принадлежит consumer. */
19
20
  export interface STableSort<Field extends string = string> {