Skip to content

Commit a8bd38d

Browse files
Merge pull request #354 from appwrite/feat-SER-205-new-expansion-table-component
2 parents a3ac011 + 73fb121 commit a8bd38d

File tree

6 files changed

+784
-0
lines changed

6 files changed

+784
-0
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts">
2+
import type { RootProp } from '../index.js';
3+
import Icon from '$lib/Icon.svelte';
4+
import { IconChevronDown } from '@appwrite.io/pink-icons-svelte';
5+
import Button from '$lib/button/Button.svelte';
6+
import { getContext } from 'svelte';
7+
8+
export let root: RootProp;
9+
export let column: string;
10+
export let expandable: boolean = true;
11+
const rowId = getContext<string>('rowId');
12+
13+
$: columnIndex = root.columns.findIndex((c) => c.id === column);
14+
$: justify = root.alignment(root.columns[columnIndex]?.align);
15+
$: isFirstCell = columnIndex === 0;
16+
$: isOpen = rowId ? root.isOpen(rowId) : false;
17+
$: toggle = rowId ? () => root.toggle(rowId) : () => {};
18+
</script>
19+
20+
<div class="cell" style="justify-content: {justify};">
21+
{#if isFirstCell && expandable}
22+
<Button
23+
variant="ghost"
24+
icon
25+
size="s"
26+
on:click={toggle}
27+
aria-expanded={isOpen}
28+
aria-label={isOpen ? 'Collapse row' : 'Expand row'}
29+
>
30+
<span class="chevron" class:open={isOpen}>
31+
<Icon icon={IconChevronDown} size="s" />
32+
</span>
33+
</Button>
34+
{/if}
35+
<slot />
36+
</div>
37+
38+
<style lang="scss">
39+
.cell {
40+
display: flex;
41+
align-items: center;
42+
gap: var(--space-2, 4px);
43+
height: 100%;
44+
}
45+
46+
.chevron {
47+
display: flex;
48+
transition: rotate 300ms ease-in-out;
49+
}
50+
51+
.chevron.open {
52+
rotate: 180deg;
53+
}
54+
</style>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Root from './root/Root.svelte';
2+
import Row from './row/Row.svelte';
3+
import Cell from './cell/Cell.svelte';
4+
5+
export type ExpandableTableColumn = {
6+
id: string;
7+
title?: string;
8+
width?: string; // e.g. '2fr'
9+
align?: 'left' | 'center' | 'right';
10+
};
11+
12+
export type RootProp = {
13+
single: boolean;
14+
openIds: string[];
15+
isOpen: (id: string) => boolean;
16+
open: (id: string) => void;
17+
close: (id: string) => void;
18+
toggle: (id: string) => void;
19+
register: (id: string) => void;
20+
unregister: (id: string) => void;
21+
columns: ExpandableTableColumn[];
22+
gridTemplateColumns: string;
23+
childGridTemplate: string;
24+
alignment: (align?: 'left' | 'center' | 'right') => string;
25+
};
26+
27+
export default {
28+
Root,
29+
Row,
30+
Cell
31+
};
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<script lang="ts">
2+
import type { ExpandableTableColumn } from '../index.js';
3+
import Text from '$lib/typography/Text.svelte';
4+
5+
export let single: boolean = false;
6+
export let openIds: string[] = [];
7+
export let columns: ExpandableTableColumn[] = [];
8+
export let showHeader: boolean = true;
9+
10+
let registeredIds: Set<string> = new Set();
11+
12+
function isOpen(id: string): boolean {
13+
return openIds.includes(id);
14+
}
15+
16+
function open(id: string): void {
17+
if (!registeredIds.has(id)) return;
18+
if (single) {
19+
openIds = [id];
20+
} else if (!openIds.includes(id)) {
21+
openIds = [...openIds, id];
22+
}
23+
}
24+
25+
function close(id: string): void {
26+
if (!registeredIds.has(id)) return;
27+
openIds = openIds.filter((openId) => openId !== id);
28+
}
29+
30+
function toggle(id: string): void {
31+
if (isOpen(id)) close(id);
32+
else open(id);
33+
}
34+
35+
function register(id: string): void {
36+
registeredIds.add(id);
37+
registeredIds = registeredIds;
38+
}
39+
40+
function unregister(id: string): void {
41+
registeredIds.delete(id);
42+
registeredIds = registeredIds;
43+
openIds = openIds.filter((openId) => openId !== id);
44+
}
45+
46+
// Grid column templates
47+
$: baseColumnWidths = columns.length ? columns.map((c) => c.width ?? '1fr').join(' ') : '1fr';
48+
$: gridTemplateColumns = baseColumnWidths;
49+
$: childGridTemplate = baseColumnWidths;
50+
51+
const alignment = (align?: 'left' | 'center' | 'right') => {
52+
switch (align) {
53+
case 'right':
54+
return 'flex-end';
55+
case 'center':
56+
return 'center';
57+
default:
58+
return 'flex-start';
59+
}
60+
};
61+
62+
$: root = {
63+
single,
64+
openIds,
65+
isOpen,
66+
open,
67+
close,
68+
toggle,
69+
register,
70+
unregister,
71+
columns,
72+
gridTemplateColumns,
73+
childGridTemplate,
74+
alignment
75+
};
76+
</script>
77+
78+
<div class="expandable-table">
79+
{#if showHeader && columns.length}
80+
<div class="table-header" style="grid-template-columns: {gridTemplateColumns};">
81+
<slot name="header">
82+
{#each columns as col}
83+
<div class="header-cell" style="justify-content: {alignment(col.align)};">
84+
<Text variant="m-500" color="--fgcolor-neutral-secondary">{col.title}</Text>
85+
</div>
86+
{/each}
87+
</slot>
88+
</div>
89+
{/if}
90+
91+
<div class="table-body">
92+
<slot {root} />
93+
</div>
94+
</div>
95+
96+
<style lang="scss">
97+
.expandable-table {
98+
--row-pad-top: var(--space-2, 4px);
99+
--row-pad-bottom: var(--space-2, 4px);
100+
--row-pad-left: var(--space-4, 12px);
101+
--row-pad-right: var(--space-4, 12px);
102+
--row-gap: 4px;
103+
--row-height: 40px;
104+
105+
--divider-color: var(--border-neutral, rgba(0, 0, 0, 0.12));
106+
--divider-strong: var(--border-neutral-strong, rgba(0, 0, 0, 0.18));
107+
--overlay-hover: var(--overlay-neutral-hover, rgba(0, 0, 0, 0.04));
108+
--row-open-bg: var(--bgcolor-neutral-default, rgba(0, 0, 0, 0.02));
109+
--accordion-bg: var(--bgcolor-neutral-default, var(--row-open-bg));
110+
111+
border: var(--border-width-s, 1px) solid var(--divider-strong);
112+
border-radius: var(--border-radius-s);
113+
background: var(--bgcolor-neutral-primary, #fff);
114+
overflow-x: auto;
115+
width: 100%;
116+
}
117+
118+
@media (prefers-color-scheme: dark) {
119+
.expandable-table {
120+
--divider-color: var(--border-neutral, rgba(255, 255, 255, 0.08));
121+
--divider-strong: var(--border-neutral-strong, rgba(255, 255, 255, 0.12));
122+
--overlay-hover: var(--overlay-neutral-hover, rgba(255, 255, 255, 0.02));
123+
--row-open-bg: var(--bgcolor-neutral-default-dark, rgba(255, 255, 255, 0.02));
124+
}
125+
}
126+
127+
.table-header {
128+
display: grid;
129+
align-items: center;
130+
padding: var(--row-pad-top) var(--row-pad-right) var(--row-pad-bottom) var(--row-pad-left);
131+
background: var(--bgcolor-neutral-tertiary, #fff);
132+
border-bottom: var(--border-width-s, 1px) solid var(--divider-strong);
133+
height: var(--row-height);
134+
box-sizing: border-box;
135+
}
136+
.header-cell {
137+
display: flex;
138+
align-items: center;
139+
}
140+
141+
/* Responsive (maintain consistent height) */
142+
@media (max-width: 480px) {
143+
.table-header {
144+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
145+
padding: var(--row-pad-top) var(--space-2, 4px) var(--row-pad-bottom)
146+
var(--space-2, 4px);
147+
}
148+
}
149+
@media (min-width: 481px) and (max-width: 768px) {
150+
.table-header {
151+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
152+
padding: var(--row-pad-top) var(--space-3, 8px) var(--row-pad-bottom)
153+
var(--space-3, 8px);
154+
}
155+
}
156+
@media (min-width: 1200px) {
157+
.table-header {
158+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
159+
padding: var(--row-pad-top) var(--space-6, 16px) var(--row-pad-bottom)
160+
var(--space-6, 16px);
161+
}
162+
}
163+
</style>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { setContext } from 'svelte';
4+
import type { RootProp } from '../index.js';
5+
import { slide } from 'svelte/transition';
6+
7+
export let root: RootProp;
8+
export let id: string;
9+
export let expandable: boolean = true;
10+
11+
setContext('rowId', id);
12+
13+
onMount(() => {
14+
if (id) root.register(id);
15+
return () => {
16+
if (id) root.unregister(id);
17+
};
18+
});
19+
20+
$: isOpen = root.isOpen(id);
21+
</script>
22+
23+
<div class="table-row" class:has-children={expandable} class:is-open={isOpen}>
24+
<div class="row-content" style="grid-template-columns: {root.gridTemplateColumns};">
25+
<slot />
26+
</div>
27+
28+
{#if isOpen}
29+
<div class="expanded-content" transition:slide={{ duration: 200 }}>
30+
<slot name="summary" {root} />
31+
</div>
32+
{/if}
33+
</div>
34+
35+
<style lang="scss">
36+
.table-row.has-children:not(.is-open):hover .row-content {
37+
background: var(--overlay-hover);
38+
}
39+
.table-row.is-open .row-content {
40+
background: var(--row-open-bg);
41+
border-bottom-color: var(--divider-strong);
42+
}
43+
44+
.row-content {
45+
display: grid;
46+
align-items: center;
47+
padding: var(--row-pad-top) var(--row-pad-right) var(--row-pad-bottom) var(--row-pad-left);
48+
border-bottom: var(--border-width-s, 1px) solid var(--divider-color);
49+
height: var(--row-height);
50+
box-sizing: border-box;
51+
transition: background-color 0.2s ease;
52+
}
53+
54+
.expanded-content {
55+
background: var(--accordion-bg);
56+
padding: 0;
57+
position: relative;
58+
}
59+
.expanded-content::after {
60+
content: '';
61+
position: absolute;
62+
left: 0;
63+
right: 0;
64+
bottom: 0;
65+
height: calc(var(--border-width-s, 1px) * 2);
66+
background: var(--divider-strong);
67+
box-shadow: 0 -1px 0 var(--divider-color) inset;
68+
z-index: 1;
69+
pointer-events: none;
70+
}
71+
72+
:global(.child-row) {
73+
display: grid;
74+
align-items: center;
75+
padding: var(--row-pad-top) var(--row-pad-right) var(--row-pad-bottom) var(--row-pad-left);
76+
border-bottom: var(--border-width-s, 1px) solid var(--divider-color);
77+
height: var(--row-height);
78+
box-sizing: border-box;
79+
background: transparent;
80+
color: var(--fgcolor-neutral-secondary, rgba(0, 0, 0, 0.6));
81+
}
82+
:global(.child-row:last-child) {
83+
border-bottom: none;
84+
}
85+
:global(.child-cell) {
86+
display: flex;
87+
align-items: center;
88+
}
89+
90+
/* responsiveness */
91+
@media (max-width: 480px) {
92+
.row-content,
93+
:global(.child-row) {
94+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
95+
padding: var(--row-pad-top) var(--space-2, 4px) var(--row-pad-bottom)
96+
var(--space-2, 4px);
97+
}
98+
:global(.child-cell) {
99+
gap: var(--space-1, 2px);
100+
}
101+
}
102+
@media (min-width: 481px) and (max-width: 768px) {
103+
.row-content,
104+
:global(.child-row) {
105+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
106+
padding: var(--row-pad-top) var(--space-3, 8px) var(--row-pad-bottom)
107+
var(--space-3, 8px);
108+
}
109+
}
110+
@media (min-width: 1200px) {
111+
.row-content,
112+
:global(.child-row) {
113+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
114+
padding: var(--row-pad-top) var(--space-6, 16px) var(--row-pad-bottom)
115+
var(--space-6, 16px);
116+
}
117+
}
118+
</style>

v2/pink-sb/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { default as AvatarGroup } from './avatar/AvatarGroup.svelte';
33
export { default as Badge } from './Badge.svelte';
44
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
55
export { default as Divider } from './Divider.svelte';
6+
export { default as Expandable } from './expandable-table/index.js';
67
export { default as FloatingActionBar } from './FloatingActionBar.svelte';
78
export { default as InteractiveText } from './InteractiveText.svelte';
89
export { default as Root } from './Root.svelte';

0 commit comments

Comments
 (0)