Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
524 changes: 427 additions & 97 deletions civicpatch/src/frontend/build/bundle.js

Large diffs are not rendered by default.

391 changes: 391 additions & 0 deletions civicpatch/src/frontend/components/basic/table/cell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,391 @@
import { useState, component, useEffect, useLayoutEffect, useRef } from 'haunted';
import { html, css } from 'lit';
import { ref, createRef } from 'lit/directives/ref.js';
import { keyed } from 'lit/directives/keyed.js';

const KEYCODES = {
ENTER: 'Enter',
TAB: 'Tab',
BACKSPACE: 'Backspace',
}

// TODO: Already this needs some refactoring ewww
// Styles are kind of unreadable (generated by claude)
// & keyboard logic might be better off moved to the parent
// This cell should not really care about

const styles = css`
civ-table-cell {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;

div {
width: 100%;
height: 100%;
min-height: 1em;
display: flex;
align-items: stretch;
}

span {
flex: 1;
width: 100%;
height: 100%;
display: block;
min-height: 1em;
box-sizing: border-box;
border: 2px solid transparent;
padding: 0.4rem;
white-space: normal;
word-break: break-word;
overflow-wrap: break-word;
}

span.cell-content[contenteditable="true"]:focus {
box-sizing: border-box;
outline: 2px solid rgb(var(--catppuccin-mauve));
border-radius: var(--pico-border-radius);
}
div.tag-list:focus-within {
outline: 2px solid rgb(var(--catppuccin-mauve));
border-radius: var(--pico-border-radius);
}

.tag-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
align-content: flex-start;
gap: 0.25rem;
padding: 0.4rem;
width: 100%;
height: 100%;
box-sizing: border-box;
}

.tag-list button:hover {
background: rgb(var(--catppuccin-sapphire), 0.2);
border-color: rgb(var(--catppuccin-crust));
}

.tag-list button {
appearance: none;
border: 1px solid rgb(var(--catppuccin-crust));
background: rgb(var(--catppuccin-base));
border-radius: 999px;
padding: 0.1rem 0.35rem 0.1rem 0.5rem;
font-size: 0.72rem;
font-family: inherit;
cursor: pointer;
color: #333;
width: fit-content; /* shrinks to content */
max-width: 200px; /* but won't exceed this */
display: inline-flex;
align-items: center;
gap: 0;
line-height: 1;
}

.tag-list button span.tag-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
margin-right: 0.2rem;
flex: auto;
}

.tag-list button span.tag-remove {
flex: content;
}

.tag-input {
flex-basis: 100%;
min-height: 1.2em;
outline: none;
border: none;
background: transparent;
padding: 0;
font-size: 0.8rem;
font-family: inherit;
color: inherit;
white-space: normal;
word-break: break-word;
overflow-wrap: break-word;
}

.tag-input:empty::before {
content: attr(data-placeholder);
color: #aaa;
pointer-events: none;
}
}
`;

function TableCell({
identifier,
rowIndex,
colIndex,
field,
type,
format,
value,
focused,
editing,
customCell,
data
}) {
const [editList, setEditList] = useState(() => Array.isArray(value) ? value : []);
const divRef = createRef();

const contentEditableRef = createRef();
const checkboxRef = createRef();

useEffect(() => {
// Sync local state when parent value changes (e.g., after save/cancel)
setEditList(Array.isArray(value) ? value : []);
}, [value]);

useLayoutEffect(() => {
if (type === "checkbox" && (focused || editing)) {
checkboxRef.current.focus()
return;
}

// Generic editing
if (editing) {
if (contentEditableRef.current) {
contentEditableRef.current.focus();
// Move caret to end
const range = document.createRange();
range.selectNodeContents(contentEditableRef.current);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
} else if (focused) {
divRef.current.focus();
}
}, [focused, editing]);

const handleSingleCellKeyDown = (e) => {
if (e.key === KEYCODES.ENTER) {
e.preventDefault();

if (editing) {
e.stopPropagation();
const newValue = contentEditableRef.current?.innerText ?? '';

// Reset input
contentEditableRef.current.innerText = '';
dispatchDataChange(e, newValue);
}
}
}

function renderSingleCell() {
if (!editing) {
return keyed('display', html`
<span class="cell-content">${value}</span>
`);
}
return keyed('edit', html`
<span
class="cell-content"
contenteditable="true"
@keydown=${handleSingleCellKeyDown}
${ref(el => {
contentEditableRef.current = el;
if (el && !el.dataset.initialized) {
el.innerText = value ?? '';
el.dataset.initialized = 'true';
}
})}
></span>
`);
}

function handleAddItem(e, newItem) {
let newList = [...editList]

if (newItem && newItem.trim() !== '') {
newList = [...newList, newItem.trim()];
}
setEditList(newList);

dispatchDataChange(e, newList);

return newList;
}

function handleRemoveItem(e, index) {
// Only update local state, do not dispatch event
const newList = editList.filter((_, i) => i !== index);
setEditList(newList);

return newList;
}

function dispatchDataChange(e, newValue) {
divRef.current?.dispatchEvent(new CustomEvent('cell-change', {
detail: { identifier, field, value: newValue, row: rowIndex, col: colIndex },
bubbles: true,
composed: true
}));
}

function handleListInputKeyDown(e) {
if (!editing) return;
if (e.key === KEYCODES.ENTER) {
e.preventDefault();
const newItem = e.target.innerText.trim();
handleAddItem(e, newItem)
} else if (e.key === KEYCODES.BACKSPACE) {
console.log("handling input...", editList)
// If there is input, return
// Else, remove a button
const input = e.target.innerText.trim();
console.log('what is', input, !!input, input.length)
if (input && input.length > 0) {
return;
}

if (editList.length === 0) {
return;
}
// Visual update only -- commits only when Entered
handleRemoveItem(e, editList.length - 1);
}
}

function handleCheckboxKeyDown(e) {
if (e.key === KEYCODES.SPACE) {
e.preventDefault()
}
}

function renderListCell() {
const displayWithFormat = item => {
switch (format) {
case 'phone':
return html`<a href="tel:${item}" class="tag-link" tabindex="-1">${item}</a>`;
case 'email':
return html`<a href="mailto:${item}" class="tag-link" tabindex="-1">${item}</a>`;
default: // Regular link
return html`<a href="${item}" target="_blank" rel="noopener noreferrer" class="tag-link" tabindex="-1">${item}</a>`;
}
};

return html`
<div class="tag-list ${editing ? 'editing' : ''}">
${editList.map((item, i) => editing
? html`
<button type="button" @click=${e => handleRemoveItem(e, i)}>
<span class="tag-label">${item}</span>
<span class="tag-remove">×</span>
</button>`
: displayWithFormat(item)
)}
${editing ? html`
<span
class="tag-input"
contenteditable="true"
data-placeholder="Add…"
@keydown=${handleListInputKeyDown}
${ref(el => {
contentEditableRef.current = el;
})}
></span>
` : ''}
</div>
`;
}

function renderImageCell() {
return html`
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; padding: 0.25rem; box-sizing: border-box;">
${value
? html`<img src="${value}" alt="Profile image" style="
width: min(100%, 100cqh, 4rem);
height: min(100%, 100cqh, 4rem);
border-radius: 50%;
object-fit: cover;
object-position: center;
display: block;
flex-shrink: 0;
" />`
: html`<div style="
width: min(100%, 100cqh, 4rem);
height: min(100%, 100cqh, 4rem);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 0.7rem;
color: #aaa;
">?</div>`
}
</div>
`;
}

function renderCheckboxCell() {
return html`
<div style="display:flex;
align-items:center;
justify-content:center;
height:100%;
margin:0 1rem"
>
<input type="checkbox" style="margin: 0" tabIndex="-1"
${ref(el => {
checkboxRef.current = el;
if (el && !el.dataset.initialized) {
el.checked = !!value;
el.dataset.initialized = 'true';
}
})}
@keydown=${handleCheckboxKeyDown}
@change=${(e) => dispatchDataChange(e, e.target.checked)}
/>
</div>
`;
}

function renderCell() {
if (customCell) {
return customCell(data);
}

switch (type) {
case 'multiple':
return renderListCell();
case 'image':
return renderImageCell();
case 'checkbox':
return renderCheckboxCell()
default:
return renderSingleCell();
}
}

return html`
<style>${styles}</style>
<div
tabIndex="-1"
data-field=${field}
${ref(el => {
divRef.current = el;
})}
>
${renderCell()}
</div>
`;
}

customElements.define('civ-table-cell', component(TableCell, { observedAttributes: ['identifier', 'rowIndex', 'colIndex', 'field', 'value'], useShadowDOM: false }));
Empty file.
Empty file.
Loading