@pienter/ui 0.2.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/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- package/utils/validation/rules.ts +31 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* ============================================================
|
|
3
|
+
* Block — the native <details> element. The browser handles
|
|
4
|
+
* expand/collapse via the [open] attribute; we layer styling.
|
|
5
|
+
* ============================================================ */
|
|
6
|
+
.pui-collapsible {
|
|
7
|
+
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
8
|
+
border-radius: var(--radius-md);
|
|
9
|
+
background: var(--bg-clr-surface);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* ============================================================
|
|
13
|
+
* Summary — the focusable / clickable trigger. Native <summary>
|
|
14
|
+
* carries activation semantics for free; we style it to look
|
|
15
|
+
* like a clickable row with a chevron affordance.
|
|
16
|
+
* ============================================================ */
|
|
17
|
+
.pui-collapsible__trigger {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: space-between;
|
|
21
|
+
gap: var(--space-s);
|
|
22
|
+
padding: var(--space-2xs) var(--space-s);
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
font: inherit;
|
|
25
|
+
font-weight: var(--fw-semibold);
|
|
26
|
+
color: var(--text-clr-base);
|
|
27
|
+
list-style: none; /* hide default disclosure triangle (Firefox) */
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Chrome / Safari hide the default marker with this pseudo. */
|
|
31
|
+
.pui-collapsible__trigger::-webkit-details-marker {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.pui-collapsible__trigger:hover {
|
|
36
|
+
background: var(--bg-clr-surface-2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pui-collapsible__trigger:focus-visible {
|
|
40
|
+
outline: 3px solid var(--outline-clr-base);
|
|
41
|
+
outline-offset: 2px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pui-collapsible__label {
|
|
45
|
+
flex: 1 1 auto;
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ============================================================
|
|
50
|
+
* Chevron — rotates to indicate open state via [open] attribute
|
|
51
|
+
* on the parent <details>. Matches Sidebar group toggle and the
|
|
52
|
+
* locked CSS-tokens-only rule.
|
|
53
|
+
* ============================================================ */
|
|
54
|
+
.pui-collapsible__chevron {
|
|
55
|
+
flex: 0 0 auto;
|
|
56
|
+
color: var(--text-clr-muted);
|
|
57
|
+
transition: transform 180ms ease;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.pui-collapsible[open]
|
|
61
|
+
> .pui-collapsible__trigger
|
|
62
|
+
> .pui-collapsible__chevron {
|
|
63
|
+
transform: rotate(180deg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ============================================================
|
|
67
|
+
* Content — the disclosure panel. Border separates it visually
|
|
68
|
+
* from the summary; padding matches the trigger's inline padding
|
|
69
|
+
* for vertical alignment.
|
|
70
|
+
* ============================================================ */
|
|
71
|
+
.pui-collapsible__content {
|
|
72
|
+
padding: var(--space-s);
|
|
73
|
+
border-block-start: var(--stroke-sm) solid var(--border-clr-base);
|
|
74
|
+
color: var(--text-clr-base);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './separator.css';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
orientation?: 'horizontal' | 'vertical';
|
|
6
|
+
decorative?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { orientation = 'horizontal', decorative = false, ...rest } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
decorative ? (
|
|
14
|
+
<div
|
|
15
|
+
class="pui-separator"
|
|
16
|
+
data-orientation={orientation}
|
|
17
|
+
role="none"
|
|
18
|
+
{...rest}
|
|
19
|
+
/>
|
|
20
|
+
) : orientation === 'vertical' ? (
|
|
21
|
+
<div
|
|
22
|
+
class="pui-separator"
|
|
23
|
+
data-orientation="vertical"
|
|
24
|
+
role="separator"
|
|
25
|
+
aria-orientation="vertical"
|
|
26
|
+
{...rest}
|
|
27
|
+
/>
|
|
28
|
+
) : (
|
|
29
|
+
<hr class="pui-separator" data-orientation="horizontal" {...rest} />
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="decorative"
|
|
4
|
+
class="pui-separator"
|
|
5
|
+
:data-orientation="orientation"
|
|
6
|
+
role="none"
|
|
7
|
+
/>
|
|
8
|
+
<div
|
|
9
|
+
v-else-if="orientation === 'vertical'"
|
|
10
|
+
class="pui-separator"
|
|
11
|
+
data-orientation="vertical"
|
|
12
|
+
role="separator"
|
|
13
|
+
aria-orientation="vertical"
|
|
14
|
+
/>
|
|
15
|
+
<hr v-else class="pui-separator" data-orientation="horizontal" />
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
orientation?: 'horizontal' | 'vertical';
|
|
22
|
+
decorative?: boolean;
|
|
23
|
+
}>(),
|
|
24
|
+
{
|
|
25
|
+
orientation: 'horizontal',
|
|
26
|
+
decorative: false,
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
@import './separator.css';
|
|
33
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-separator {
|
|
3
|
+
/* Reset native <hr> defaults so the component fully owns sizing.
|
|
4
|
+
* Browsers paint <hr> with a 1px inset border and default block
|
|
5
|
+
* margins; both are zeroed and replaced with token-driven values. */
|
|
6
|
+
flex-shrink: 0;
|
|
7
|
+
border: 0;
|
|
8
|
+
margin: 0;
|
|
9
|
+
background-color: var(--border-clr-base);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.pui-separator[data-orientation='horizontal'] {
|
|
13
|
+
display: block;
|
|
14
|
+
width: 100%;
|
|
15
|
+
height: var(--stroke-sm);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.pui-separator[data-orientation='vertical'] {
|
|
19
|
+
display: inline-block;
|
|
20
|
+
width: var(--stroke-sm);
|
|
21
|
+
height: 100%;
|
|
22
|
+
/* Allow the consumer's flex/grid context to size the height; falls
|
|
23
|
+
* back to 1em when the parent does not constrain block-size. */
|
|
24
|
+
min-height: 1em;
|
|
25
|
+
align-self: stretch;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
DataTable — the data-driven wrapper over `Table` (ADR 0003), generic over the row type T.
|
|
3
|
+
Cells render from `row[column.key]` unless overridden by a `#cell:<key>="{ row, value }"` slot.
|
|
4
|
+
-->
|
|
5
|
+
<template>
|
|
6
|
+
<Table
|
|
7
|
+
:sort="sort"
|
|
8
|
+
:loading="loading"
|
|
9
|
+
:columns="columns"
|
|
10
|
+
:selectable="selectable"
|
|
11
|
+
:clickable="clickable"
|
|
12
|
+
:all-selected="allSelected"
|
|
13
|
+
@toggle-all="toggleAll"
|
|
14
|
+
@sort="(key) => emit('sort', key)"
|
|
15
|
+
>
|
|
16
|
+
<tr
|
|
17
|
+
v-for="row in rows"
|
|
18
|
+
:key="String(keyOf(row))"
|
|
19
|
+
class="pui-table__row"
|
|
20
|
+
:data-selected="isSelected(keyOf(row)) || undefined"
|
|
21
|
+
:data-clickable="clickable || undefined"
|
|
22
|
+
@click="onRowClick($event, row)"
|
|
23
|
+
>
|
|
24
|
+
<td v-if="selectable" class="pui-table__cell" data-select>
|
|
25
|
+
<input
|
|
26
|
+
class="pui-table__check"
|
|
27
|
+
type="checkbox"
|
|
28
|
+
:checked="isSelected(keyOf(row))"
|
|
29
|
+
aria-label="Select row"
|
|
30
|
+
@click.stop
|
|
31
|
+
@change="toggle(keyOf(row))"
|
|
32
|
+
/>
|
|
33
|
+
</td>
|
|
34
|
+
<td
|
|
35
|
+
v-for="col in columns"
|
|
36
|
+
:key="col.key"
|
|
37
|
+
class="pui-table__cell"
|
|
38
|
+
:data-align="col.align"
|
|
39
|
+
>
|
|
40
|
+
<slot
|
|
41
|
+
:name="`cell:${col.key}`"
|
|
42
|
+
:row="row"
|
|
43
|
+
:value="cellValue(row, col.key)"
|
|
44
|
+
>
|
|
45
|
+
{{ cellValue(row, col.key) }}
|
|
46
|
+
</slot>
|
|
47
|
+
</td>
|
|
48
|
+
</tr>
|
|
49
|
+
<!-- Suppressed while loading: an empty `rows` mid-fetch means "not yet", not "nothing". -->
|
|
50
|
+
<tr v-if="!rows.length && !loading" class="pui-table__row">
|
|
51
|
+
<td class="pui-table__empty">
|
|
52
|
+
<slot name="empty">No results.</slot>
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</Table>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<script setup lang="ts" generic="T extends object">
|
|
59
|
+
import { computed } from 'vue';
|
|
60
|
+
import Table from './Table.vue';
|
|
61
|
+
import type { Column, SortState } from './types.js';
|
|
62
|
+
|
|
63
|
+
const props = withDefaults(
|
|
64
|
+
defineProps<{
|
|
65
|
+
/** Column layout. */
|
|
66
|
+
columns: Column[];
|
|
67
|
+
/** Row data. */
|
|
68
|
+
rows: T[];
|
|
69
|
+
/** Property on each row that uniquely identifies it. */
|
|
70
|
+
rowKey?: string;
|
|
71
|
+
/** Selected row keys — use with `v-model:selected`. */
|
|
72
|
+
selected?: (string | number)[];
|
|
73
|
+
/** Render a selection gutter (checkbox column). */
|
|
74
|
+
selectable?: boolean;
|
|
75
|
+
/** Show a pointer cursor on rows. */
|
|
76
|
+
clickable?: boolean;
|
|
77
|
+
/** Which column is sorted, and how. `rows` is passed through untouched — ordering them is the consumer's job (ADR 0005). */
|
|
78
|
+
sort?: SortState;
|
|
79
|
+
/** Frosts and disables the body while the rows are stale, and suppresses the empty state. */
|
|
80
|
+
loading?: boolean;
|
|
81
|
+
}>(),
|
|
82
|
+
{
|
|
83
|
+
rowKey: 'id',
|
|
84
|
+
selected: () => [],
|
|
85
|
+
selectable: false,
|
|
86
|
+
clickable: false,
|
|
87
|
+
sort: null,
|
|
88
|
+
loading: false,
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const emit = defineEmits<{
|
|
93
|
+
'update:selected': [keys: (string | number)[]];
|
|
94
|
+
'row-click': [row: T];
|
|
95
|
+
/** A sortable header was activated, carrying that column's `key`. */
|
|
96
|
+
sort: [key: string];
|
|
97
|
+
}>();
|
|
98
|
+
|
|
99
|
+
/** Read a field off a row by key; interface row types lack an index signature, hence the cast. */
|
|
100
|
+
function cellValue(row: T, key: string): unknown {
|
|
101
|
+
return (row as Record<string, unknown>)[key];
|
|
102
|
+
}
|
|
103
|
+
function keyOf(row: T): string | number {
|
|
104
|
+
return cellValue(row, props.rowKey) as string | number;
|
|
105
|
+
}
|
|
106
|
+
function isSelected(key: string | number): boolean {
|
|
107
|
+
return props.selected.includes(key);
|
|
108
|
+
}
|
|
109
|
+
function toggle(key: string | number) {
|
|
110
|
+
const next = isSelected(key)
|
|
111
|
+
? props.selected.filter((k) => k !== key)
|
|
112
|
+
: [...props.selected, key];
|
|
113
|
+
emit('update:selected', next);
|
|
114
|
+
}
|
|
115
|
+
const allSelected = computed(
|
|
116
|
+
() =>
|
|
117
|
+
props.rows.length > 0 && props.rows.every((r) => isSelected(keyOf(r))),
|
|
118
|
+
);
|
|
119
|
+
function toggleAll() {
|
|
120
|
+
emit('update:selected', allSelected.value ? [] : props.rows.map(keyOf));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onRowClick(event: MouseEvent, row: T) {
|
|
124
|
+
if ((event.target as HTMLElement).closest('button, a')) return;
|
|
125
|
+
emit('row-click', row);
|
|
126
|
+
}
|
|
127
|
+
</script>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Table (Astro) — the static primitive; the consumer authors the body in the default slot with
|
|
3
|
+
// the BEM classes. Sortable headers are <a>s, not events: sorting a static table is a navigation.
|
|
4
|
+
import './table.css';
|
|
5
|
+
import Icon from '../../display/icon/Icon.astro';
|
|
6
|
+
import Spinner from '../../feedback/spinner/Spinner.astro';
|
|
7
|
+
import type { IconName } from '../../../icons/index.js';
|
|
8
|
+
import {
|
|
9
|
+
formatSort,
|
|
10
|
+
toggleSort,
|
|
11
|
+
type SortDirection,
|
|
12
|
+
type SortState,
|
|
13
|
+
} from '../../../utils/sort/index.js';
|
|
14
|
+
import type { Column } from './types.js';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
columns: Column[];
|
|
18
|
+
/** Which column is sorted, and how. Rendered here; the page orders the rows itself (ADR 0005). */
|
|
19
|
+
sort?: SortState;
|
|
20
|
+
/** Query parameter the header links read and write. */
|
|
21
|
+
sortParam?: string;
|
|
22
|
+
/** Frosts and disables the body while the rows are stale, leaving the header usable. */
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
columns,
|
|
28
|
+
sort = null,
|
|
29
|
+
sortParam = 'sort',
|
|
30
|
+
loading = false,
|
|
31
|
+
...rest
|
|
32
|
+
} = Astro.props;
|
|
33
|
+
const cols = columns.map((c) => c.width ?? 'auto').join(' ');
|
|
34
|
+
|
|
35
|
+
/** Header glyph per sort state — the shape carries the direction. */
|
|
36
|
+
const SORT_ICONS: Record<SortDirection | 'none', IconName> = {
|
|
37
|
+
asc: 'chevron-up',
|
|
38
|
+
desc: 'chevron-down',
|
|
39
|
+
none: 'chevron-up-down',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function sortState(key: string): SortDirection | 'none' {
|
|
43
|
+
return sort?.key === key ? sort.direction : 'none';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `aria-sort` token for the header cell; only the sorted column carries one. */
|
|
47
|
+
function ariaSort(key: string): 'ascending' | 'descending' | undefined {
|
|
48
|
+
if (sort?.key !== key) return undefined;
|
|
49
|
+
return sort.direction === 'asc' ? 'ascending' : 'descending';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where this column's header links: the asc → desc → unsorted cycle, other query params preserved. */
|
|
53
|
+
function sortHref(key: string): string {
|
|
54
|
+
const url = new URL(Astro.url);
|
|
55
|
+
const value = formatSort(toggleSort(sort, key));
|
|
56
|
+
if (value) url.searchParams.set(sortParam, value);
|
|
57
|
+
else url.searchParams.delete(sortParam);
|
|
58
|
+
return url.pathname + url.search;
|
|
59
|
+
}
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<div class="pui-table-wrap" {...rest}>
|
|
63
|
+
<table
|
|
64
|
+
class="pui-table"
|
|
65
|
+
style={`--pui-table-cols: ${cols}`}
|
|
66
|
+
aria-busy={loading ? 'true' : undefined}
|
|
67
|
+
>
|
|
68
|
+
<thead class="pui-table__head">
|
|
69
|
+
<tr class="pui-table__row" data-head>
|
|
70
|
+
{
|
|
71
|
+
columns.map((col) => (
|
|
72
|
+
<th
|
|
73
|
+
scope="col"
|
|
74
|
+
class="pui-table__cell"
|
|
75
|
+
data-head
|
|
76
|
+
data-align={col.align}
|
|
77
|
+
aria-sort={col.sortable ? ariaSort(col.key) : undefined}
|
|
78
|
+
>
|
|
79
|
+
{col.sortable ? (
|
|
80
|
+
<a class="pui-table__sort" href={sortHref(col.key)}>
|
|
81
|
+
{col.label}
|
|
82
|
+
<span
|
|
83
|
+
class="pui-table__sort-arrow"
|
|
84
|
+
data-sort={sortState(col.key)}
|
|
85
|
+
>
|
|
86
|
+
<Icon name={SORT_ICONS[sortState(col.key)]} size="sm" />
|
|
87
|
+
</span>
|
|
88
|
+
</a>
|
|
89
|
+
) : (
|
|
90
|
+
col.label
|
|
91
|
+
)}
|
|
92
|
+
</th>
|
|
93
|
+
))
|
|
94
|
+
}
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
{
|
|
98
|
+
/* `inert`, not CSS: `pointer-events` only stops the mouse and leaves the stale rows tabbable. */
|
|
99
|
+
}
|
|
100
|
+
<tbody class="pui-table__body" inert={loading ? true : undefined}>
|
|
101
|
+
<slot />
|
|
102
|
+
{
|
|
103
|
+
/* Must be a real <tr>/<td> inside the body, or the HTML parser hoists it out — see AUDIT.md. */
|
|
104
|
+
}
|
|
105
|
+
{
|
|
106
|
+
loading && (
|
|
107
|
+
<tr class="pui-table__loading" aria-hidden="true">
|
|
108
|
+
<td>
|
|
109
|
+
<Spinner size="xl" />
|
|
110
|
+
</td>
|
|
111
|
+
</tr>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Table — the primitive shell (ADR 0003): header from `columns`, default slot for the body,
|
|
3
|
+
which takes `TableRow`s or plain `<tr>`s. Column layout is provided to child `TableRow`s.
|
|
4
|
+
-->
|
|
5
|
+
<template>
|
|
6
|
+
<div class="pui-table-wrap">
|
|
7
|
+
<table
|
|
8
|
+
class="pui-table"
|
|
9
|
+
:style="{ '--pui-table-cols': cols }"
|
|
10
|
+
:aria-busy="loading || undefined"
|
|
11
|
+
>
|
|
12
|
+
<thead class="pui-table__head">
|
|
13
|
+
<tr class="pui-table__row" data-head>
|
|
14
|
+
<th
|
|
15
|
+
v-if="selectable"
|
|
16
|
+
class="pui-table__cell"
|
|
17
|
+
data-head
|
|
18
|
+
data-select
|
|
19
|
+
>
|
|
20
|
+
<input
|
|
21
|
+
class="pui-table__check"
|
|
22
|
+
type="checkbox"
|
|
23
|
+
:checked="allSelected"
|
|
24
|
+
aria-label="Select all rows"
|
|
25
|
+
@change="emit('toggle-all')"
|
|
26
|
+
/>
|
|
27
|
+
</th>
|
|
28
|
+
<th
|
|
29
|
+
v-for="col in columns"
|
|
30
|
+
:key="col.key"
|
|
31
|
+
class="pui-table__cell"
|
|
32
|
+
data-head
|
|
33
|
+
:data-align="col.align"
|
|
34
|
+
:aria-sort="
|
|
35
|
+
col.sortable ? ariaSort(col.key) : undefined
|
|
36
|
+
"
|
|
37
|
+
scope="col"
|
|
38
|
+
>
|
|
39
|
+
<button
|
|
40
|
+
v-if="col.sortable"
|
|
41
|
+
type="button"
|
|
42
|
+
class="pui-table__sort"
|
|
43
|
+
@click="emit('sort', col.key)"
|
|
44
|
+
>
|
|
45
|
+
{{ col.label }}
|
|
46
|
+
<span
|
|
47
|
+
class="pui-table__sort-arrow"
|
|
48
|
+
:data-sort="state(col.key)"
|
|
49
|
+
>
|
|
50
|
+
<Icon
|
|
51
|
+
:name="SORT_ICONS[state(col.key)]"
|
|
52
|
+
size="sm"
|
|
53
|
+
/>
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
<template v-else>{{ col.label }}</template>
|
|
57
|
+
</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<!-- `inert`, not CSS: `pointer-events` only stops the mouse and leaves the stale rows tabbable. -->
|
|
61
|
+
<tbody class="pui-table__body" :inert="loading || undefined">
|
|
62
|
+
<slot />
|
|
63
|
+
<!-- Inside the body so it covers the rows and not the header; must be a real
|
|
64
|
+
<tr>/<td> or the HTML parser hoists it out of the table — see AUDIT.md. -->
|
|
65
|
+
<tr
|
|
66
|
+
v-if="loading"
|
|
67
|
+
class="pui-table__loading"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
>
|
|
70
|
+
<td><Spinner size="xl" /></td>
|
|
71
|
+
</tr>
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts">
|
|
78
|
+
import { computed, provide } from 'vue';
|
|
79
|
+
import Icon from '../../display/icon/Icon.vue';
|
|
80
|
+
import Spinner from '../../feedback/spinner/Spinner.vue';
|
|
81
|
+
import type { IconName } from '../../../icons/index.js';
|
|
82
|
+
import type { SortDirection, SortState } from '../../../utils/sort/index.js';
|
|
83
|
+
import { TABLE_COLUMNS, type Column } from './types.js';
|
|
84
|
+
import { gridColumns } from './useTable.js';
|
|
85
|
+
|
|
86
|
+
/** Header glyph per sort state — the shape carries the direction. */
|
|
87
|
+
const SORT_ICONS: Record<SortDirection | 'none', IconName> = {
|
|
88
|
+
asc: 'chevron-up',
|
|
89
|
+
desc: 'chevron-down',
|
|
90
|
+
none: 'chevron-up-down',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const props = withDefaults(
|
|
94
|
+
defineProps<{
|
|
95
|
+
/** Column layout. */
|
|
96
|
+
columns: Column[];
|
|
97
|
+
/** Render a leading selection gutter (checkbox column). */
|
|
98
|
+
selectable?: boolean;
|
|
99
|
+
/** Show a pointer cursor on rows (for clickable/selectable rows). */
|
|
100
|
+
clickable?: boolean;
|
|
101
|
+
/** Checked state of the header select-all checkbox. */
|
|
102
|
+
allSelected?: boolean;
|
|
103
|
+
/** Which column is sorted, and how. Rendered, never changed here; `null` is unsorted (ADR 0005). */
|
|
104
|
+
sort?: SortState;
|
|
105
|
+
/** Frosts and disables the body while the rows are stale, leaving the header usable. */
|
|
106
|
+
loading?: boolean;
|
|
107
|
+
}>(),
|
|
108
|
+
{
|
|
109
|
+
selectable: false,
|
|
110
|
+
clickable: false,
|
|
111
|
+
allSelected: false,
|
|
112
|
+
sort: null,
|
|
113
|
+
loading: false,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const emit = defineEmits<{
|
|
118
|
+
'toggle-all': [];
|
|
119
|
+
/** A sortable header was activated, carrying that column's `key` — the intent, not the next state (ADR 0005). */
|
|
120
|
+
sort: [key: string];
|
|
121
|
+
}>();
|
|
122
|
+
|
|
123
|
+
// TableRow reads layout from here instead of taking `columns` again.
|
|
124
|
+
provide(TABLE_COLUMNS, {
|
|
125
|
+
columns: computed(() => props.columns),
|
|
126
|
+
selectable: computed(() => props.selectable),
|
|
127
|
+
clickable: computed(() => props.clickable),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const cols = computed(() => gridColumns(props.columns, props.selectable));
|
|
131
|
+
|
|
132
|
+
/** Sort state of one column, driving the header glyph. */
|
|
133
|
+
function state(key: string): SortDirection | 'none' {
|
|
134
|
+
return props.sort?.key === key ? props.sort.direction : 'none';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `aria-sort` token for the header cell; only the sorted column carries one. */
|
|
138
|
+
function ariaSort(key: string): 'ascending' | 'descending' | undefined {
|
|
139
|
+
if (props.sort?.key !== key) return undefined;
|
|
140
|
+
return props.sort.direction === 'asc' ? 'ascending' : 'descending';
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
@import './table.css';
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TableRow — one row of the composed primitive. One named slot per column key (`#<key>`).
|
|
3
|
+
Reads column layout from the parent `Table` via inject. Emits `row-click`, auto-suppressed
|
|
4
|
+
when the click lands on a button or link inside the row, so action controls coexist with a
|
|
5
|
+
clickable/selectable row. Selection is externalized: the caller owns the set and passes `selected`.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<tr
|
|
9
|
+
class="pui-table__row"
|
|
10
|
+
:data-selected="selected || undefined"
|
|
11
|
+
:data-clickable="ctx.clickable.value || undefined"
|
|
12
|
+
@click="onClick"
|
|
13
|
+
>
|
|
14
|
+
<td v-if="ctx.selectable.value" class="pui-table__cell" data-select>
|
|
15
|
+
<input
|
|
16
|
+
class="pui-table__check"
|
|
17
|
+
type="checkbox"
|
|
18
|
+
:checked="selected"
|
|
19
|
+
tabindex="-1"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
@click.prevent
|
|
22
|
+
/>
|
|
23
|
+
</td>
|
|
24
|
+
<td
|
|
25
|
+
v-for="col in ctx.columns.value"
|
|
26
|
+
:key="col.key"
|
|
27
|
+
class="pui-table__cell"
|
|
28
|
+
:data-align="col.align"
|
|
29
|
+
>
|
|
30
|
+
<slot :name="col.key" />
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { inject } from 'vue';
|
|
37
|
+
import { TABLE_COLUMNS } from './types.js';
|
|
38
|
+
|
|
39
|
+
withDefaults(
|
|
40
|
+
defineProps<{
|
|
41
|
+
/** Selected styling. Selection state is owned by the caller. */
|
|
42
|
+
selected?: boolean;
|
|
43
|
+
}>(),
|
|
44
|
+
{ selected: false },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{ 'row-click': [event: MouseEvent] }>();
|
|
48
|
+
|
|
49
|
+
const ctx = inject(TABLE_COLUMNS);
|
|
50
|
+
if (!ctx) {
|
|
51
|
+
throw new Error('[pui] TableRow must be used inside a Table.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onClick(event: MouseEvent) {
|
|
55
|
+
// Don't hijack clicks on interactive controls inside the row.
|
|
56
|
+
if ((event.target as HTMLElement).closest('button, a')) return;
|
|
57
|
+
emit('row-click', event);
|
|
58
|
+
}
|
|
59
|
+
</script>
|