@cahyo-dimas/freeday 1.9.0 → 1.10.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 CHANGED
@@ -3,6 +3,32 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.10.0] — 2026-07-28
7
+ Follow-ups from the doc-ai-automation migration (improvement notes #27–#30). Additive / corrective;
8
+ no breaking changes.
9
+
10
+ ### Fixed
11
+ - **`<fieldset class="fdy-field">` no longer leaks native browser chrome (#27).** The kit shipped no
12
+ `fieldset` reset, so a grouped field (a radio/checkbox group with a `<legend>`) showed the UA groove
13
+ border, extra padding, and `min-inline-size:min-content` — the last of which also blocked the
14
+ filterbar column widths from shrinking it. Added a scoped `fieldset.fdy-field` reset so it lays out
15
+ identically to the `<div>` form.
16
+ - **Chart x-axis: the last label no longer collides with its neighbour (#29).** `freeday-chart.js`
17
+ force-drew the final label with no spacing check, so on a dense axis (e.g. 24 hourly points) the
18
+ last two labels fused. It now estimates the widest label instead of assuming a fixed 40px slot, and
19
+ drops the penultimate label when the forced-last one would overlap it. Visual only; sparse axes
20
+ render identically.
21
+
22
+ ### Added
23
+ - **`FdyTable` expandable detail rows (#30).** A `row-detail` slot (Vue) / `renderRowDetail` prop
24
+ (React) renders a full-width row beneath any expanded row, driven by a **controlled** `expandedKeys`
25
+ (matches the sort/filter/page contract; pairs with `row-activate` as the toggle). Adds a
26
+ `.fdy-table__detailrow` class (muted panel, no hover tint). Removes the last interaction that kept a
27
+ table hand-rolled.
28
+ - **`.fdy-field--w-2xl` (25rem) filterbar column (#28).** Wide enough for a two-picker date range, so
29
+ it need not burn the single `--w-grow` slot. The date-range pickers also gained a `min-width` (7rem)
30
+ so a squeezed range compresses and wraps instead of clipping the date (was `min-width:0`).
31
+
6
32
  ## [1.9.0] — 2026-07-28
7
33
  Follow-ups to 1.8.0's `FdyTable` (improvement notes #25/#26). Additive — the #25 change **widens** a
8
34
  generic bound, so every call site that compiled before still compiles.
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.9.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.9.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.10.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.10.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.9.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.9.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.10.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.10.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties, JSX, ReactNode } from 'react';
2
- import { useEffect, useMemo, useState } from 'react';
2
+ import { Fragment, useEffect, useMemo, useState } from 'react';
3
3
  import {
4
4
  cellValue,
5
5
  cellText,
@@ -57,6 +57,10 @@ export interface FdyTableProps<Row extends object> {
57
57
  rowClass?: (row: Row) => string | undefined;
58
58
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
59
59
  onRowActivate?: (row: Row) => void;
60
+ /** Controlled: row keys whose detail is shown as a full-width row beneath them. */
61
+ expandedKeys?: ReadonlyArray<string | number>;
62
+ /** Renders the expandable detail row for an expanded row (React equivalent of Vue's `row-detail` slot). */
63
+ renderRowDetail?: (row: Row) => ReactNode;
60
64
  }
61
65
 
62
66
  export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Element {
@@ -164,6 +168,9 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
164
168
  e.preventDefault();
165
169
  props.onRowActivate?.(row);
166
170
  }
171
+ function isExpanded(row: Row): boolean {
172
+ return props.expandedKeys?.includes(props.rowKey(row)) === true;
173
+ }
167
174
  function renderCellContent(col: FdyTableColumn<Row>, row: Row): ReactNode {
168
175
  if (props.renderCell !== undefined) {
169
176
  const custom: ReactNode = props.renderCell(col, row, cellValue(row, col));
@@ -213,19 +220,26 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
213
220
  </tr>
214
221
  ) : (
215
222
  displayRows.map((row: Row): JSX.Element => (
216
- <tr
217
- key={props.rowKey(row)}
218
- className={rowClassName(row)}
219
- tabIndex={props.rowActivatable ? 0 : undefined}
220
- onClick={(): void => {
221
- if (props.rowActivatable === true) props.onRowActivate?.(row);
222
- }}
223
- onKeyDown={(e): void => onRowKeydown(e, row)}
224
- >
225
- {props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
226
- <td key={col.key} className={cellClass(col)} style={alignStyle(col)}>{renderCellContent(col, row)}</td>
227
- ))}
228
- </tr>
223
+ <Fragment key={props.rowKey(row)}>
224
+ <tr
225
+ className={rowClassName(row)}
226
+ tabIndex={props.rowActivatable ? 0 : undefined}
227
+ aria-expanded={props.renderRowDetail !== undefined ? (isExpanded(row) ? 'true' : 'false') : undefined}
228
+ onClick={(): void => {
229
+ if (props.rowActivatable === true) props.onRowActivate?.(row);
230
+ }}
231
+ onKeyDown={(e): void => onRowKeydown(e, row)}
232
+ >
233
+ {props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
234
+ <td key={col.key} className={cellClass(col)} style={alignStyle(col)}>{renderCellContent(col, row)}</td>
235
+ ))}
236
+ </tr>
237
+ {props.renderRowDetail !== undefined && isExpanded(row) && (
238
+ <tr className="fdy-table__detailrow">
239
+ <td colSpan={colCount}>{props.renderRowDetail(row)}</td>
240
+ </tr>
241
+ )}
242
+ </Fragment>
229
243
  ))
230
244
  )}
231
245
  </tbody>
@@ -48,6 +48,8 @@ const props = defineProps<{
48
48
  rowActivatable?: boolean;
49
49
  /** Per-row class hook, e.g. to mark a selected row. */
50
50
  rowClass?: (row: Row) => string | undefined;
51
+ /** Controlled: row keys whose `row-detail` slot is shown as a full-width row beneath them. */
52
+ expandedKeys?: ReadonlyArray<string | number>;
51
53
  }>();
52
54
 
53
55
  const emit = defineEmits<{
@@ -184,6 +186,9 @@ function onRowKeydown(e: KeyboardEvent, row: Row): void {
184
186
  e.preventDefault();
185
187
  emit('row-activate', row);
186
188
  }
189
+ function isExpanded(row: Row): boolean {
190
+ return props.expandedKeys?.includes(props.rowKey(row)) === true;
191
+ }
187
192
  </script>
188
193
 
189
194
  <template>
@@ -224,19 +229,24 @@ function onRowKeydown(e: KeyboardEvent, row: Row): void {
224
229
  <slot name="empty">{{ emptyText ?? 'No data' }}</slot>
225
230
  </td>
226
231
  </tr>
227
- <tr
228
- v-for="row in displayRows"
229
- v-else
230
- :key="rowKey(row)"
231
- :class="rowClasses(row)"
232
- :tabindex="rowActivatable ? 0 : undefined"
233
- @click="onRowClick(row)"
234
- @keydown="onRowKeydown($event, row)"
235
- >
236
- <td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
237
- <slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
238
- </td>
239
- </tr>
232
+ <template v-else>
233
+ <template v-for="row in displayRows" :key="rowKey(row)">
234
+ <tr
235
+ :class="rowClasses(row)"
236
+ :tabindex="rowActivatable ? 0 : undefined"
237
+ :aria-expanded="$slots['row-detail'] ? (isExpanded(row) ? 'true' : 'false') : undefined"
238
+ @click="onRowClick(row)"
239
+ @keydown="onRowKeydown($event, row)"
240
+ >
241
+ <td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
242
+ <slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
243
+ </td>
244
+ </tr>
245
+ <tr v-if="$slots['row-detail'] && isExpanded(row)" class="fdy-table__detailrow">
246
+ <td :colspan="columns.length"><slot name="row-detail" :row="row" /></td>
247
+ </tr>
248
+ </template>
249
+ </template>
240
250
  </tbody>
241
251
  </table>
242
252
  </div>
@@ -213,17 +213,31 @@
213
213
  svg.appendChild(tk);
214
214
  }
215
215
 
216
- // x category labels with autoskip.
217
- var maxTicks = Math.max(2, Math.floor(plotW / 40)), step = Math.ceil(n / maxTicks);
216
+ // x category labels with autoskip. Labels are author-supplied, so estimate the widest one instead
217
+ // of assuming a fixed 40px slot; keep every step-th plus the last, then drop any kept label that
218
+ // would collide with the one before it — including the forced last one (a fraction-of-a-slot
219
+ // overlap happens when n-1 lands next to the last kept multiple of step, e.g. n=24 at most widths).
220
+ var charW = 5.4; // ~9px font advance (.fdy-chart-xy__xlabel)
221
+ var widest = 0;
222
+ for (var wi = 0; wi < n; wi++) widest = Math.max(widest, String(labels[wi] || ('#' + (wi + 1))).length * charW);
223
+ var minGap = widest + 8;
224
+ var maxTicks = Math.max(2, Math.floor(plotW / minGap)), step = Math.ceil(n / maxTicks);
225
+ var xPos = function (i) { return (type === 'bar') ? xCenter(i) : xLine(i); };
226
+ var kept = [];
218
227
  for (var xi = 0; xi < n; xi++) {
219
- if (xi % step !== 0 && xi !== n - 1) continue;
220
- var lx = (type === 'bar') ? xCenter(xi) : xLine(xi);
228
+ if (xi % step === 0 || xi === n - 1) kept.push(xi);
229
+ }
230
+ // The last label is worth forcing, but not on top of its neighbour — drop the penultimate one.
231
+ if (kept.length >= 2 && xPos(kept[kept.length - 1]) - xPos(kept[kept.length - 2]) < minGap) {
232
+ kept.splice(kept.length - 2, 1);
233
+ }
234
+ kept.forEach(function (xk) {
221
235
  var xt = svgEl('text');
222
- xt.setAttribute('x', lx.toFixed(1)); xt.setAttribute('y', H - 6);
236
+ xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - 6);
223
237
  xt.setAttribute('text-anchor', 'middle'); xt.setAttribute('class', 'fdy-chart-xy__xlabel');
224
- xt.textContent = labels[xi] || ('#' + (xi + 1));
238
+ xt.textContent = labels[xk] || ('#' + (xk + 1));
225
239
  svg.appendChild(xt);
226
- }
240
+ });
227
241
 
228
242
  if (type === 'bar') {
229
243
  var S = series.length;
@@ -875,7 +875,9 @@ a { color: var(--color-primary); }
875
875
  /* Responsive: each picker may shrink (min-width:0) & wrap so the range never overflows a
876
876
  narrow row/phone; inline-flex still hugs to ~23rem at wider widths (look unchanged). */
877
877
  .fdy-daterange{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);}
878
- .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:0;}
878
+ /* Each picker shrinks from an 11rem basis but not below ~7rem — enough for "dd/mm/yyyy" + the icon,
879
+ so a squeezed range compresses gracefully (and wraps) instead of clipping the date. */
880
+ .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:7rem;}
879
881
  .fdy-daterange__sep{flex:none;color:var(--color-text-subtle);}
880
882
 
881
883
  @media (prefers-reduced-motion:no-preference){.fdy-datepicker__panel:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
@@ -978,13 +980,15 @@ a { color: var(--color-primary); }
978
980
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
979
981
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
980
982
  .fdy-filterbar>.fdy-field--w-xl{width:18rem;}
983
+ /* Wide enough for a two-picker date range (~25rem) so it need not burn the single --w-grow slot. */
984
+ .fdy-filterbar>.fdy-field--w-2xl{width:25rem;}
981
985
  /* One growing field (usually the search) absorbs the remaining width. */
982
986
  .fdy-filterbar>.fdy-field--w-grow{flex:1 1 16rem;width:auto;min-width:12rem;}
983
987
  /* Trailing actions (buttons) pushed to the end, aligned with the controls' baseline. */
984
988
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
985
989
  /* Stack on narrow screens so nothing overflows the row. */
986
990
  @media (max-width:640px){
987
- .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
991
+ .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-2xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
988
992
  .fdy-filterbar__actions{width:100%;margin-left:0;}
989
993
  }
990
994
 
@@ -1022,6 +1026,11 @@ a { color: var(--color-primary); }
1022
1026
 
1023
1027
  /* Freeday — Field & Input */
1024
1028
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
1029
+ /* A grouped field is legitimately a <fieldset> (it needs a <legend>) — e.g. a radio/checkbox group.
1030
+ Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
1031
+ <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
1032
+ fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
1033
+ fieldset.fdy-field>legend{padding:0;float:none;}
1025
1034
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
1026
1035
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
1027
1036
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -1223,6 +1232,8 @@ a { color: var(--color-primary); }
1223
1232
  comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
1224
1233
  .fdy-table__row--activatable{cursor:pointer;}
1225
1234
  .fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
1235
+ /* Expandable detail row (FdyTable row-detail slot): a muted panel under its row, no hover tint. */
1236
+ .fdy-table__detailrow>td,.fdy-table__detailrow:hover>td{background:var(--color-surface-2);}
1226
1237
  .fdy-table th[aria-sort]{cursor:pointer;}
1227
1238
  .fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
1228
1239
  .fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
package/dist/freeday.css CHANGED
@@ -530,7 +530,9 @@ a { color: var(--color-primary); }
530
530
  /* Responsive: each picker may shrink (min-width:0) & wrap so the range never overflows a
531
531
  narrow row/phone; inline-flex still hugs to ~23rem at wider widths (look unchanged). */
532
532
  .fdy-daterange{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);}
533
- .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:0;}
533
+ /* Each picker shrinks from an 11rem basis but not below ~7rem — enough for "dd/mm/yyyy" + the icon,
534
+ so a squeezed range compresses gracefully (and wraps) instead of clipping the date. */
535
+ .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:7rem;}
534
536
  .fdy-daterange__sep{flex:none;color:var(--color-text-subtle);}
535
537
 
536
538
  @media (prefers-reduced-motion:no-preference){.fdy-datepicker__panel:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
@@ -633,13 +635,15 @@ a { color: var(--color-primary); }
633
635
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
634
636
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
635
637
  .fdy-filterbar>.fdy-field--w-xl{width:18rem;}
638
+ /* Wide enough for a two-picker date range (~25rem) so it need not burn the single --w-grow slot. */
639
+ .fdy-filterbar>.fdy-field--w-2xl{width:25rem;}
636
640
  /* One growing field (usually the search) absorbs the remaining width. */
637
641
  .fdy-filterbar>.fdy-field--w-grow{flex:1 1 16rem;width:auto;min-width:12rem;}
638
642
  /* Trailing actions (buttons) pushed to the end, aligned with the controls' baseline. */
639
643
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
640
644
  /* Stack on narrow screens so nothing overflows the row. */
641
645
  @media (max-width:640px){
642
- .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
646
+ .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-2xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
643
647
  .fdy-filterbar__actions{width:100%;margin-left:0;}
644
648
  }
645
649
 
@@ -677,6 +681,11 @@ a { color: var(--color-primary); }
677
681
 
678
682
  /* Freeday — Field & Input */
679
683
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
684
+ /* A grouped field is legitimately a <fieldset> (it needs a <legend>) — e.g. a radio/checkbox group.
685
+ Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
686
+ <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
687
+ fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
688
+ fieldset.fdy-field>legend{padding:0;float:none;}
680
689
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
681
690
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
682
691
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -878,6 +887,8 @@ a { color: var(--color-primary); }
878
887
  comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
879
888
  .fdy-table__row--activatable{cursor:pointer;}
880
889
  .fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
890
+ /* Expandable detail row (FdyTable row-detail slot): a muted panel under its row, no hover tint. */
891
+ .fdy-table__detailrow>td,.fdy-table__detailrow:hover>td{background:var(--color-surface-2);}
881
892
  .fdy-table th[aria-sort]{cursor:pointer;}
882
893
  .fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
883
894
  .fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
package/dist/freeday.js CHANGED
@@ -986,17 +986,31 @@
986
986
  svg.appendChild(tk);
987
987
  }
988
988
 
989
- // x category labels with autoskip.
990
- var maxTicks = Math.max(2, Math.floor(plotW / 40)), step = Math.ceil(n / maxTicks);
989
+ // x category labels with autoskip. Labels are author-supplied, so estimate the widest one instead
990
+ // of assuming a fixed 40px slot; keep every step-th plus the last, then drop any kept label that
991
+ // would collide with the one before it — including the forced last one (a fraction-of-a-slot
992
+ // overlap happens when n-1 lands next to the last kept multiple of step, e.g. n=24 at most widths).
993
+ var charW = 5.4; // ~9px font advance (.fdy-chart-xy__xlabel)
994
+ var widest = 0;
995
+ for (var wi = 0; wi < n; wi++) widest = Math.max(widest, String(labels[wi] || ('#' + (wi + 1))).length * charW);
996
+ var minGap = widest + 8;
997
+ var maxTicks = Math.max(2, Math.floor(plotW / minGap)), step = Math.ceil(n / maxTicks);
998
+ var xPos = function (i) { return (type === 'bar') ? xCenter(i) : xLine(i); };
999
+ var kept = [];
991
1000
  for (var xi = 0; xi < n; xi++) {
992
- if (xi % step !== 0 && xi !== n - 1) continue;
993
- var lx = (type === 'bar') ? xCenter(xi) : xLine(xi);
1001
+ if (xi % step === 0 || xi === n - 1) kept.push(xi);
1002
+ }
1003
+ // The last label is worth forcing, but not on top of its neighbour — drop the penultimate one.
1004
+ if (kept.length >= 2 && xPos(kept[kept.length - 1]) - xPos(kept[kept.length - 2]) < minGap) {
1005
+ kept.splice(kept.length - 2, 1);
1006
+ }
1007
+ kept.forEach(function (xk) {
994
1008
  var xt = svgEl('text');
995
- xt.setAttribute('x', lx.toFixed(1)); xt.setAttribute('y', H - 6);
1009
+ xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - 6);
996
1010
  xt.setAttribute('text-anchor', 'middle'); xt.setAttribute('class', 'fdy-chart-xy__xlabel');
997
- xt.textContent = labels[xi] || ('#' + (xi + 1));
1011
+ xt.textContent = labels[xk] || ('#' + (xk + 1));
998
1012
  svg.appendChild(xt);
999
- }
1013
+ });
1000
1014
 
1001
1015
  if (type === 'bar') {
1002
1016
  var S = series.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,9 @@
38
38
  /* Responsive: each picker may shrink (min-width:0) & wrap so the range never overflows a
39
39
  narrow row/phone; inline-flex still hugs to ~23rem at wider widths (look unchanged). */
40
40
  .fdy-daterange{display:inline-flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);}
41
- .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:0;}
41
+ /* Each picker shrinks from an 11rem basis but not below ~7rem — enough for "dd/mm/yyyy" + the icon,
42
+ so a squeezed range compresses gracefully (and wraps) instead of clipping the date. */
43
+ .fdy-daterange .fdy-datepicker{flex:0 1 11rem;min-width:7rem;}
42
44
  .fdy-daterange__sep{flex:none;color:var(--color-text-subtle);}
43
45
 
44
46
  @media (prefers-reduced-motion:no-preference){.fdy-datepicker__panel:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
@@ -14,12 +14,14 @@
14
14
  .fdy-filterbar>.fdy-field--w-sm{width:9rem;}
15
15
  .fdy-filterbar>.fdy-field--w-lg{width:15rem;}
16
16
  .fdy-filterbar>.fdy-field--w-xl{width:18rem;}
17
+ /* Wide enough for a two-picker date range (~25rem) so it need not burn the single --w-grow slot. */
18
+ .fdy-filterbar>.fdy-field--w-2xl{width:25rem;}
17
19
  /* One growing field (usually the search) absorbs the remaining width. */
18
20
  .fdy-filterbar>.fdy-field--w-grow{flex:1 1 16rem;width:auto;min-width:12rem;}
19
21
  /* Trailing actions (buttons) pushed to the end, aligned with the controls' baseline. */
20
22
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
21
23
  /* Stack on narrow screens so nothing overflows the row. */
22
24
  @media (max-width:640px){
23
- .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
25
+ .fdy-filterbar>.fdy-field,.fdy-filterbar>.fdy-field--w-sm,.fdy-filterbar>.fdy-field--w-lg,.fdy-filterbar>.fdy-field--w-xl,.fdy-filterbar>.fdy-field--w-2xl,.fdy-filterbar>.fdy-field--w-grow{width:100%;flex:1 1 100%;}
24
26
  .fdy-filterbar__actions{width:100%;margin-left:0;}
25
27
  }
@@ -1,5 +1,10 @@
1
1
  /* Freeday — Field & Input */
2
2
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
3
+ /* A grouped field is legitimately a <fieldset> (it needs a <legend>) — e.g. a radio/checkbox group.
4
+ Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
5
+ <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
6
+ fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
7
+ fieldset.fdy-field>legend{padding:0;float:none;}
3
8
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
4
9
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
5
10
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -19,6 +19,8 @@
19
19
  comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
20
20
  .fdy-table__row--activatable{cursor:pointer;}
21
21
  .fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
22
+ /* Expandable detail row (FdyTable row-detail slot): a muted panel under its row, no hover tint. */
23
+ .fdy-table__detailrow>td,.fdy-table__detailrow:hover>td{background:var(--color-surface-2);}
22
24
  .fdy-table th[aria-sort]{cursor:pointer;}
23
25
  .fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
24
26
  .fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
@@ -213,17 +213,31 @@
213
213
  svg.appendChild(tk);
214
214
  }
215
215
 
216
- // x category labels with autoskip.
217
- var maxTicks = Math.max(2, Math.floor(plotW / 40)), step = Math.ceil(n / maxTicks);
216
+ // x category labels with autoskip. Labels are author-supplied, so estimate the widest one instead
217
+ // of assuming a fixed 40px slot; keep every step-th plus the last, then drop any kept label that
218
+ // would collide with the one before it — including the forced last one (a fraction-of-a-slot
219
+ // overlap happens when n-1 lands next to the last kept multiple of step, e.g. n=24 at most widths).
220
+ var charW = 5.4; // ~9px font advance (.fdy-chart-xy__xlabel)
221
+ var widest = 0;
222
+ for (var wi = 0; wi < n; wi++) widest = Math.max(widest, String(labels[wi] || ('#' + (wi + 1))).length * charW);
223
+ var minGap = widest + 8;
224
+ var maxTicks = Math.max(2, Math.floor(plotW / minGap)), step = Math.ceil(n / maxTicks);
225
+ var xPos = function (i) { return (type === 'bar') ? xCenter(i) : xLine(i); };
226
+ var kept = [];
218
227
  for (var xi = 0; xi < n; xi++) {
219
- if (xi % step !== 0 && xi !== n - 1) continue;
220
- var lx = (type === 'bar') ? xCenter(xi) : xLine(xi);
228
+ if (xi % step === 0 || xi === n - 1) kept.push(xi);
229
+ }
230
+ // The last label is worth forcing, but not on top of its neighbour — drop the penultimate one.
231
+ if (kept.length >= 2 && xPos(kept[kept.length - 1]) - xPos(kept[kept.length - 2]) < minGap) {
232
+ kept.splice(kept.length - 2, 1);
233
+ }
234
+ kept.forEach(function (xk) {
221
235
  var xt = svgEl('text');
222
- xt.setAttribute('x', lx.toFixed(1)); xt.setAttribute('y', H - 6);
236
+ xt.setAttribute('x', xPos(xk).toFixed(1)); xt.setAttribute('y', H - 6);
223
237
  xt.setAttribute('text-anchor', 'middle'); xt.setAttribute('class', 'fdy-chart-xy__xlabel');
224
- xt.textContent = labels[xi] || ('#' + (xi + 1));
238
+ xt.textContent = labels[xk] || ('#' + (xk + 1));
225
239
  svg.appendChild(xt);
226
- }
240
+ });
227
241
 
228
242
  if (type === 'bar') {
229
243
  var S = series.length;