Skip to content

Commit d5feb0c

Browse files
committed
Sorter component: add options to fix first or last item
1 parent 6a08140 commit d5feb0c

File tree

4 files changed

+146
-13
lines changed

4 files changed

+146
-13
lines changed

cypress/e2e/sorter.cy.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,70 @@ describe('Test sorter component', () => {
132132
.and('not.have.class', 'is-disabled');
133133
});
134134

135+
it('fixed first item has correct attributes', () => {
136+
cy.get('sorter-component[name="fixed_items"]')
137+
.find('.sortable-item')
138+
.first()
139+
.should('have.attr', 'data-fixed', 'true')
140+
.and('have.attr', 'draggable', 'false')
141+
.and('have.attr', 'data-enabled', 'true')
142+
.and('have.attr', 'data-value', 'Always First');
143+
});
144+
145+
it('fixed last item has correct attributes', () => {
146+
cy.get('sorter-component[name="fixed_items"]')
147+
.find('.sortable-item')
148+
.last()
149+
.should('have.attr', 'data-fixed', 'true')
150+
.and('have.attr', 'draggable', 'false')
151+
.and('have.attr', 'data-enabled', 'true')
152+
.and('have.attr', 'data-value', 'Always Last');
153+
});
154+
155+
it('fixed items have no checkbox even when checkboxes enabled', () => {
156+
cy.get('sorter-component[name="fixed_items"]')
157+
.find('.sortable-item')
158+
.first()
159+
.find('input[type="checkbox"]')
160+
.should('have.length', 0);
161+
162+
cy.get('sorter-component[name="fixed_items"]')
163+
.find('.sortable-item')
164+
.last()
165+
.find('input[type="checkbox"]')
166+
.should('have.length', 0);
167+
});
168+
169+
it('fixed items have no drag handle', () => {
170+
cy.get('sorter-component[name="fixed_items"]')
171+
.find('.sortable-item')
172+
.first()
173+
.find('.drag-handle')
174+
.should('have.length', 0);
175+
176+
cy.get('sorter-component[name="fixed_items"]')
177+
.find('.sortable-item')
178+
.last()
179+
.find('.drag-handle')
180+
.should('have.length', 0);
181+
});
182+
183+
it('non-fixed items in fixed sorter remain draggable with checkboxes', () => {
184+
cy.get('sorter-component[name="fixed_items"]')
185+
.find('.sortable-item')
186+
.eq(1)
187+
.should('not.have.attr', 'data-fixed')
188+
.and('have.attr', 'draggable', 'true')
189+
.find('input[type="checkbox"]')
190+
.should('have.length', 1);
191+
192+
cy.get('sorter-component[name="fixed_items"]')
193+
.find('.sortable-item')
194+
.eq(2)
195+
.should('not.have.attr', 'data-fixed')
196+
.and('have.attr', 'draggable', 'true')
197+
.find('.drag-handle')
198+
.should('have.length', 1);
199+
});
200+
135201
});

cypress/yaml/sorter/config.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,16 @@ form:
5151
column: "is-half"
5252
validation:
5353
required: false
54+
55+
fixed_items:
56+
type: sorter
57+
label: "Fixed Items Sorter"
58+
checkboxes: true
59+
fixFirst: true
60+
fixLast: true
61+
items:
62+
- "Always First"
63+
- "Movable A"
64+
- "Movable B"
65+
- "Movable C"
66+
- "Always Last"

doc/formelements.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,12 +466,16 @@ Options:
466466
* `items` _(required)_ - defines the available items
467467
* `alignment` _(optional)_ - sets the layout orientation, possible values are `vertical` (default) or `horizontal`
468468
* `checkboxes` _(optional)_ - set to `true` to display enable/disable checkboxes next to each item. Default is `false`, meaning items cannot be disabled. When enabled, each checkbox starts checked.
469+
* `fixFirst` _(optional)_ - set to `true` to lock the first item in place. It cannot be dragged or toggled and always stays at the top of the list. Default is `false`.
470+
* `fixLast` _(optional)_ - set to `true` to lock the last item in place. It cannot be dragged or toggled and always stays at the bottom of the list. Default is `false`.
469471

470472
```yaml
471473
<id>:
472474
type: sorter
473475
label: sorter label
474476
checkboxes: true
477+
fixFirst: true
478+
fixLast: true
475479
alignment: horizontal
476480
validation:
477481
required: true

js_src/components/SorterComponent.js

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,30 +40,37 @@ export class SorterComponent extends BaseComponent {
4040
// get items from state or config
4141
const items = this.getItems();
4242

43-
items.forEach((item) => {
43+
items.forEach((item, index) => {
4444
const listItem = document.createElement("li");
45-
const isEnabled = hasCheckboxes ? item.enabled !== false : true;
45+
const isFixed = (index === 0 && this.config.fixFirst) ||
46+
(index === items.length - 1 && this.config.fixLast);
47+
const isEnabled = isFixed ? true : (hasCheckboxes ? item.enabled !== false : true);
4648

4749
listItem.classList.add("sortable-item", "box", "is-flex", "is-align-items-center", "p-3", "m-2");
4850
if (!isEnabled) {
4951
listItem.classList.add("is-disabled");
5052
listItem.style.opacity = "0.5";
5153
}
5254

53-
listItem.setAttribute("draggable", isEnabled ? "true" : "false");
55+
listItem.setAttribute("draggable", (isEnabled && !isFixed) ? "true" : "false");
5456
listItem.setAttribute("data-value", item.value || item);
5557
listItem.setAttribute("data-enabled", isEnabled.toString());
56-
listItem.style.cursor = isEnabled ? "grab" : "default";
58+
if (isFixed) {
59+
listItem.setAttribute("data-fixed", "true");
60+
}
61+
listItem.style.cursor = (isEnabled && !isFixed) ? "grab" : "default";
5762

58-
const checkboxHtml = hasCheckboxes ? `
63+
const showCheckbox = hasCheckboxes && !isFixed;
64+
const checkboxHtml = showCheckbox ? `
5965
<label class="checkbox mr-2" style="cursor: pointer;">
6066
<input type="checkbox" ${isEnabled ? 'checked' : ''} data-toggle-item="${item.value || item}">
6167
</label>` : '';
6268

69+
const showDragHandle = !isFixed;
6370
listItem.innerHTML = `
64-
<span class="drag-handle icon is-small mr-2" style="cursor: ${isEnabled ? 'grab' : 'not-allowed'};">
71+
${showDragHandle ? `<span class="drag-handle icon is-small mr-2" style="cursor: ${isEnabled ? 'grab' : 'not-allowed'};">
6572
${this.#getDragIcon(isEnabled)}
66-
</span>
73+
</span>` : ''}
6774
${checkboxHtml}
6875
<span class="item-label ${isEnabled ? '' : 'has-text-dark'}">${item.value || item}</span>
6976
`;
@@ -160,7 +167,8 @@ export class SorterComponent extends BaseComponent {
160167
*/
161168
handleDragStart(e) {
162169
if (!e.target.classList.contains('sortable-item')) return;
163-
if (e.target.getAttribute('data-enabled') === 'false') {
170+
if (e.target.getAttribute('data-enabled') === 'false' ||
171+
e.target.getAttribute('data-fixed') === 'true') {
164172
e.preventDefault();
165173
return;
166174
}
@@ -188,8 +196,21 @@ export class SorterComponent extends BaseComponent {
188196
const afterElement = this.getDragAfterElement(sortableList, isHorizontal ? e.clientX : e.clientY, isHorizontal);
189197
const dragging = sortableList.querySelector('.dragging');
190198

199+
// Prevent placing before a fixed-first item or after a fixed-last item
200+
const children = [...sortableList.querySelectorAll('.sortable-item')];
201+
const firstChild = children[0];
202+
const lastChild = children[children.length - 1];
203+
191204
if (afterElement == null) {
192-
sortableList.appendChild(dragging);
205+
// Would append to end — block if last item is fixed
206+
if (this.config.fixLast && lastChild && lastChild.getAttribute('data-fixed') === 'true') {
207+
sortableList.insertBefore(dragging, lastChild);
208+
} else {
209+
sortableList.appendChild(dragging);
210+
}
211+
} else if (this.config.fixFirst && afterElement === firstChild && firstChild.getAttribute('data-fixed') === 'true') {
212+
// Would insert before the fixed first item — place after it instead
213+
sortableList.insertBefore(dragging, firstChild.nextSibling);
193214
} else {
194215
sortableList.insertBefore(dragging, afterElement);
195216
}
@@ -323,32 +344,61 @@ export class SorterComponent extends BaseComponent {
323344
#normalizeStateItems() {
324345
const hasCheckboxes = this.#hasCheckboxes();
325346
const stateItems = this.myState.value;
347+
const configItems = this.config.items || [];
326348

327349
if (!stateItems) {
328-
return (this.config.items || []).map(item => ({
350+
return configItems.map(item => ({
329351
value: item,
330352
enabled: true
331353
}));
332354
}
333355

334356
if (!Array.isArray(stateItems) || stateItems.length === 0) {
335-
return (this.config.items || []).map(item => ({
357+
return configItems.map(item => ({
336358
value: item,
337359
enabled: true
338360
}));
339361
}
340362

341363
const needsNormalization = stateItems.some(item => typeof item !== 'object' || item === null || !('value' in item));
342364
const shouldEnableAll = !hasCheckboxes && stateItems.some(item => item && item.enabled === false);
365+
const needsFixEnforce = this.config.fixFirst || this.config.fixLast;
343366

344-
if (!needsNormalization && !shouldEnableAll) {
367+
if (!needsNormalization && !shouldEnableAll && !needsFixEnforce) {
345368
return null;
346369
}
347370

348-
return stateItems.map(item => ({
371+
let items = stateItems.map(item => ({
349372
value: (typeof item === 'object' && item !== null && 'value' in item) ? item.value : item,
350373
enabled: hasCheckboxes ? (typeof item === 'object' && item !== null && item.enabled === false ? false : true) : true
351374
}));
375+
376+
// Ensure fixed items are in their correct positions
377+
if (this.config.fixFirst && configItems.length > 0) {
378+
const fixedValue = configItems[0];
379+
const idx = items.findIndex(i => i.value === fixedValue);
380+
if (idx > 0) {
381+
const [fixedItem] = items.splice(idx, 1);
382+
fixedItem.enabled = true;
383+
items.unshift(fixedItem);
384+
} else if (idx === 0) {
385+
items[0].enabled = true;
386+
}
387+
}
388+
389+
if (this.config.fixLast && configItems.length > 0) {
390+
const fixedValue = configItems[configItems.length - 1];
391+
const idx = items.findIndex(i => i.value === fixedValue);
392+
if (idx >= 0 && idx < items.length - 1) {
393+
const [fixedItem] = items.splice(idx, 1);
394+
fixedItem.enabled = true;
395+
items.push(fixedItem);
396+
} else if (idx === items.length - 1) {
397+
items[items.length - 1].enabled = true;
398+
}
399+
}
400+
401+
return items;
352402
}
353403
}
354404

0 commit comments

Comments
 (0)