001/*
002 * Copyright (c) 2003, 2022, Oracle and/or its affiliates. All rights reserved.
003 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
004 *
005 * This code is free software; you can redistribute it and/or modify it
006 * under the terms of the GNU General Public License version 2 only, as
007 * published by the Free Software Foundation.  Oracle designates this
008 * particular file as subject to the "Classpath" exception as provided
009 * by Oracle in the LICENSE file that accompanied this code.
010 *
011 * This code is distributed in the hope that it will be useful, but WITHOUT
012 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
013 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
014 * version 2 for more details (a copy is included in the LICENSE file that
015 * accompanied this code).
016 *
017 * You should have received a copy of the GNU General Public License version
018 * 2 along with this work; if not, write to the Free Software Foundation,
019 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
020 *
021 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
022 * or visit www.oracle.com if you need additional information or have any
023 * questions.
024 */
025
026package org.jdrupes.mdoclet.internal.doclets.formats.html;
027
028import java.io.IOException;
029import java.io.Writer;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.HashSet;
033import java.util.List;
034import java.util.Set;
035import java.util.function.Predicate;
036
037import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.ContentBuilder;
038import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.HtmlAttr;
039import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.HtmlId;
040import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.HtmlStyle;
041import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.HtmlTree;
042import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.TagName;
043import org.jdrupes.mdoclet.internal.doclets.formats.html.markup.Text;
044import org.jdrupes.mdoclet.internal.doclets.toolkit.Content;
045
046/**
047 * An HTML container used to display summary tables for various kinds of elements
048 * and other tabular data.
049 * This class historically used to generate an HTML {@code <table>} element but has been
050 * updated to render its content as a stream of {@code <div>} elements that rely on
051 * <a href="https://www.w3.org/TR/css-grid-1/">CSS Grid Layout</a> for styling.
052 * This provides for more flexible layout options, such as splitting up table rows on
053 * small displays.
054 *
055 * <p>The table should be used in three phases:
056 * <ol>
057 * <li>Configuration: the overall characteristics of the table should be specified
058 * <li>Population: the content for the cells in each row should be added
059 * <li>Generation: the HTML content and any associated JavaScript can be accessed
060 * </ol>
061 *
062 * Many methods return the current object, to facilitate fluent builder-style usage.
063 *
064 * A table may support filtered views, which can be selected by clicking on
065 * one of a list of tabs above the table. If the table does not support filtered
066 * views, the caption element is typically displayed as a single (inactive)
067 * tab.   The filtered views use a {@link Predicate} to identify the
068 * rows to be shown for each {@link #addTab(Content, Predicate) tab}. The
069 * type parameter for the predicate is the type parameter {@code T} for the table.
070 * The type parameter should be {@link Void} when the table is not configured
071 * to use tabs.
072 *
073 * @param <T> the class or interface used to distinguish the rows to be displayed
074 *            for each tab, or {@code Void} when a table does not contain tabs
075 */
076public class Table<T> extends Content {
077    private final HtmlStyle tableStyle;
078    private Content caption;
079    private List<Tab<T>> tabs;
080    private Set<Tab<T>> occurringTabs;
081    private Content defaultTab;
082    private boolean renderTabs = true;
083    private TableHeader header;
084    private List<HtmlStyle> columnStyles;
085    private HtmlStyle gridStyle;
086    private final List<Content> bodyRows;
087    private HtmlId id;
088    private boolean alwaysShowDefaultTab = false;
089
090    /**
091     * A record containing the data for a table tab.
092     */
093    record Tab<T>(Content label, Predicate<T> predicate, int index) {
094    }
095
096    /**
097     * Creates a builder for an HTML element representing a table.
098     *
099     * @param tableStyle the style class for the top-level {@code <div>} element
100     */
101    public Table(HtmlStyle tableStyle) {
102        this.tableStyle = tableStyle;
103        bodyRows = new ArrayList<>();
104    }
105
106    /**
107     * Sets the caption for the table.
108     * This is ignored if the table is configured to provide tabs to select
109     * different subsets of rows within the table.
110     *
111     * @param captionContent the caption
112     * @return this object
113     */
114    public Table<T> setCaption(Content captionContent) {
115        caption = getCaption(captionContent);
116        return this;
117    }
118
119    /**
120     * Adds a tab to the table.
121     * Tabs provide a way to display subsets of rows, as determined by a
122     * predicate for the tab, and an item associated with each row.
123     * Tabs will appear left-to-right in the order they are added.
124     *
125     * @param label     the tab label
126     * @param predicate the predicate
127     * @return this object
128     */
129    public Table<T> addTab(Content label, Predicate<T> predicate) {
130        if (tabs == null) {
131            tabs = new ArrayList<>();         // preserves order that tabs are
132                                              // added
133            occurringTabs = new HashSet<>();  // order not significant
134        }
135        // Use current size of tabs list as id so we have tab ids that are
136        // consistent
137        // across tables with the same tabs but different content.
138        tabs.add(new Tab<>(label, predicate, tabs.size() + 1));
139        return this;
140    }
141
142    /**
143     * Sets the label for the default tab, which displays all the rows in the table.
144     * This tab will appear first in the left-to-right list of displayed tabs.
145     *
146     * @param label the default tab label
147     * @return this object
148     */
149    public Table<T> setDefaultTab(Content label) {
150        defaultTab = label;
151        return this;
152    }
153
154    /**
155     * Sets whether to display the default tab even if tabs are empty or only contain a single tab.
156     * @param showDefaultTab true if default tab should always be shown
157     * @return this object
158     */
159    public Table<T> setAlwaysShowDefaultTab(boolean showDefaultTab) {
160        this.alwaysShowDefaultTab = showDefaultTab;
161        return this;
162    }
163
164    /**
165     * Allows to set whether tabs should be rendered for this table. Some pages use their
166     * own controls to select table categories, in which case the tabs are omitted.
167     *
168     * @param renderTabs true if table tabs should be rendered
169     * @return this object
170     */
171    public Table<T> setRenderTabs(boolean renderTabs) {
172        this.renderTabs = renderTabs;
173        return this;
174    }
175
176    /**
177     * Sets the header for the table.
178     *
179     * <p>Notes:
180     * <ul>
181     * <li>The column styles are not currently applied to the header, but probably should, eventually
182     * </ul>
183     *
184     * @param header the header
185     * @return this object
186     */
187    public Table<T> setHeader(TableHeader header) {
188        this.header = header;
189        return this;
190    }
191
192    /**
193     * Sets the styles for be used for the cells in each row.
194     *
195     * <p>Note:
196     * <ul>
197     * <li>The column styles are not currently applied to the header, but probably should, eventually
198     * </ul>
199     *
200     * @param styles the styles
201     * @return this object
202     */
203    public Table<T> setColumnStyles(HtmlStyle... styles) {
204        return setColumnStyles(Arrays.asList(styles));
205    }
206
207    /**
208     * Sets the styles for be used for the cells in each row.
209     *
210     * <p>Note:
211     * <ul>
212     * <li>The column styles are not currently applied to the header, but probably should, eventually
213     * </ul>
214     *
215     * @param styles the styles
216     * @return this object
217     */
218    public Table<T> setColumnStyles(List<HtmlStyle> styles) {
219        columnStyles = styles;
220        return this;
221    }
222
223    /**
224     * Sets the style for the table's grid which controls allocation of space among table columns.
225     * The style should contain a {@code display: grid;} property and its number of columns must
226     * match the number of column styles and content passed to other methods in this class.
227     *
228     * @param gridStyle the grid style
229     * @return this object
230     */
231    public Table<T> setGridStyle(HtmlStyle gridStyle) {
232        this.gridStyle = gridStyle;
233        return this;
234    }
235
236    /**
237     * Sets the id attribute of the table.
238     * This is required if the table has tabs, in which case a subsidiary id
239     * will be generated for the tabpanel. This subsidiary id is required for
240     * the ARIA support.
241     *
242     * @param id the id
243     * @return this object
244     */
245    public Table<T> setId(HtmlId id) {
246        this.id = id;
247        return this;
248    }
249
250    /**
251     * Adds a row of data to the table.
252     * Each item of content should be suitable for use as the content of a
253     * {@code <th>} or {@code <td>} cell.
254     * This method should not be used when the table has tabs: use a method
255     * that takes an {@code Element} parameter instead.
256     *
257     * @param contents the contents for the row
258     */
259    public void addRow(Content... contents) {
260        addRow(null, Arrays.asList(contents));
261    }
262
263    /**
264     * Adds a row of data to the table.
265     * Each item of content should be suitable for use as the content of a
266     * {@code <th>} or {@code <td> cell}.
267     * This method should not be used when the table has tabs: use a method
268     * that takes an {@code item} parameter instead.
269     *
270     * @param contents the contents for the row
271     */
272    public void addRow(List<Content> contents) {
273        addRow(null, contents);
274    }
275
276    /**
277     * Adds a row of data to the table.
278     * Each item of content should be suitable for use as the content of a
279     * {@code <th>} or {@code <td>} cell.
280     *
281     * If tabs have been added to the table, the specified item will be used
282     * to determine whether the row should be displayed when any particular tab
283     * is selected, using the predicate specified when the tab was
284     * {@link #addTab(Content, Predicate) added}.
285     *
286     * @param item the item
287     * @param contents the contents for the row
288     * @throws NullPointerException if tabs have previously been added to the table
289     *      and {@code item} is null
290     */
291    public void addRow(T item, Content... contents) {
292        addRow(item, Arrays.asList(contents));
293    }
294
295    /**
296     * Adds a row of data to the table.
297     * Each item of content should be suitable for use as the content of a
298     * {@code <div>} cell.
299     *
300     * If tabs have been added to the table, the specified item will be used
301     * to determine whether the row should be displayed when any particular tab
302     * is selected, using the predicate specified when the tab was
303     * {@link #addTab(Content, Predicate) added}.
304     *
305     * @param item the item
306     * @param contents the contents for the row
307     * @throws NullPointerException if tabs have previously been added to the table
308     *      and {@code item} is null
309     */
310    public void addRow(T item, List<Content> contents) {
311        if (tabs != null && item == null) {
312            throw new NullPointerException();
313        }
314        if (contents.size() != columnStyles.size()) {
315            throw new IllegalArgumentException(
316                "row content size does not match number of columns");
317        }
318
319        Content row = new ContentBuilder();
320
321        int rowIndex = bodyRows.size();
322        HtmlStyle rowStyle = rowIndex % 2 == 0 ? HtmlStyle.evenRowColor
323            : HtmlStyle.oddRowColor;
324
325        List<String> tabClasses = new ArrayList<>();
326        if (tabs != null) {
327            // Construct a series of values to add to the HTML 'class' attribute
328            // for the cells of
329            // this row, such that there is a default value and a value
330            // corresponding to each tab
331            // whose predicate matches the item. The values correspond to the
332            // equivalent ids.
333            // The values are used to determine the cells to make visible when a
334            // tab is selected.
335            tabClasses.add(id.name());
336            for (var tab : tabs) {
337                if (tab.predicate().test(item)) {
338                    occurringTabs.add(tab);
339                    tabClasses.add(HtmlIds.forTab(id, tab.index()).name());
340                }
341            }
342        }
343        int colIndex = 0;
344        for (Content c : contents) {
345            HtmlStyle cellStyle = columnStyles.get(colIndex);
346            // Always add content to make sure the cell isn't dropped
347            var cell = HtmlTree.DIV(cellStyle)
348                .addUnchecked(c.isEmpty() ? Text.EMPTY : c);
349            cell.addStyle(rowStyle);
350
351            for (String tabClass : tabClasses) {
352                cell.addStyle(tabClass);
353            }
354            row.add(cell);
355            colIndex++;
356        }
357        bodyRows.add(row);
358    }
359
360    /**
361     * Returns whether the table is empty.
362     * The table is empty if it has no (body) rows.
363     *
364     * @return true if the table has no rows
365     */
366    public boolean isEmpty() {
367        return bodyRows.isEmpty();
368    }
369
370    @Override
371    public boolean write(Writer out, String newline, boolean atNewline)
372            throws IOException {
373        return toContent().write(out, newline, atNewline);
374    }
375
376    /**
377     * Returns the HTML for the table.
378     *
379     * @return the HTML
380     */
381    private Content toContent() {
382        Content main;
383        if (id != null) {
384            main = new HtmlTree(TagName.DIV).setId(id);
385        } else {
386            main = new ContentBuilder();
387        }
388        // If no grid style is set use on of the default styles
389        if (gridStyle == null) {
390            gridStyle = switch (columnStyles.size()) {
391            case 2 -> HtmlStyle.twoColumnSummary;
392            case 3 -> HtmlStyle.threeColumnSummary;
393            case 4 -> HtmlStyle.fourColumnSummary;
394            default -> throw new IllegalStateException();
395            };
396        }
397
398        var table = HtmlTree.DIV(tableStyle).addStyle(gridStyle);
399        if ((tabs == null || occurringTabs.size() == 1)
400            && !alwaysShowDefaultTab) {
401            if (tabs == null) {
402                main.add(caption);
403            } else {
404                main.add(getCaption(occurringTabs.iterator().next().label()));
405            }
406            table.add(getTableBody());
407            main.add(table);
408        } else {
409            var tablist = HtmlTree.DIV(HtmlStyle.tableTabs)
410                .put(HtmlAttr.ROLE, "tablist")
411                .put(HtmlAttr.ARIA_ORIENTATION, "horizontal");
412
413            HtmlId defaultTabId = HtmlIds.forTab(id, 0);
414            if (renderTabs) {
415                tablist.add(createTab(defaultTabId, HtmlStyle.activeTableTab,
416                    true, defaultTab));
417            } else {
418                tablist.add(getCaption(defaultTab));
419            }
420            table.put(HtmlAttr.ARIA_LABELLEDBY, defaultTabId.name());
421            if (renderTabs) {
422                for (var tab : tabs) {
423                    if (occurringTabs.contains(tab)) {
424                        tablist.add(createTab(HtmlIds.forTab(id, tab.index()),
425                            HtmlStyle.tableTab, false, tab.label()));
426                    }
427                }
428            }
429            if (id == null) {
430                throw new IllegalStateException("no id set for table");
431            }
432            var tabpanel = new HtmlTree(TagName.DIV)
433                .setId(HtmlIds.forTabPanel(id))
434                .put(HtmlAttr.ROLE, "tabpanel");
435            table.add(getTableBody());
436            tabpanel.add(table);
437            main.add(tablist);
438            main.add(tabpanel);
439        }
440        return main;
441    }
442
443    private HtmlTree createTab(HtmlId tabId, HtmlStyle style,
444            boolean defaultTab, Content tabLabel) {
445        var tab = new HtmlTree(TagName.BUTTON)
446            .setId(tabId)
447            .put(HtmlAttr.ROLE, "tab")
448            .put(HtmlAttr.ARIA_SELECTED, defaultTab ? "true" : "false")
449            .put(HtmlAttr.ARIA_CONTROLS, HtmlIds.forTabPanel(id).name())
450            .put(HtmlAttr.TABINDEX, defaultTab ? "0" : "-1")
451            .put(HtmlAttr.ONKEYDOWN, "switchTab(event)")
452            .put(HtmlAttr.ONCLICK,
453                "show('" + id.name() + "', '" + (defaultTab ? id : tabId).name()
454                    + "', " + columnStyles.size() + ")")
455            .setStyle(style);
456        tab.add(tabLabel);
457        return tab;
458    }
459
460    private Content getTableBody() {
461        ContentBuilder tableContent = new ContentBuilder();
462        tableContent.add(header);
463        bodyRows.forEach(tableContent::add);
464        return tableContent;
465    }
466
467    private HtmlTree getCaption(Content title) {
468        return HtmlTree.DIV(HtmlStyle.caption, HtmlTree.SPAN(title));
469    }
470}