@cahyo-dimas/freeday 1.7.1 → 1.9.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.
@@ -0,0 +1,263 @@
1
+ import type { CSSProperties, JSX, ReactNode } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import {
4
+ cellValue,
5
+ cellText,
6
+ distinctValues,
7
+ filterRows,
8
+ sortRows,
9
+ paginate,
10
+ pageWindow,
11
+ } from '../../core/table-model.js';
12
+ import type {
13
+ FdyTableColumn,
14
+ FdySortState,
15
+ FdyColumnFilter,
16
+ FdyFilterMap,
17
+ FdyPageState,
18
+ } from '../../core/table-model.js';
19
+ import { FdyTableFilter } from './FdyTableFilter';
20
+
21
+ // A controlled React data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
22
+ // `.fdy-pagination__*` classes. React port of adapters/vue/components/FdyTable.vue. Unlike the
23
+ // freeday-table.js enhancer (which snapshots static rows and fights React's reconciler), this reads
24
+ // `rows` as the source of truth on every render. Two modes:
25
+ // • Client mode (no `page` prop): the component sorts/filters (and paginates when `pageSize` is
26
+ // set) over the full `rows`. `sort`/`filters` are controlled when provided, else internal.
27
+ // • Server mode (`page` prop present): `rows` render exactly as given (server already
28
+ // sorted/filtered/paged); the headers, filters and pager only signal intent via
29
+ // `onSortChange` / `onFiltersChange` / `onPageChange` for the caller to feed back into its query.
30
+ // Column filters (text/enum/number/date) apply live; in server mode, debounce the callback if needed.
31
+
32
+ export interface FdyTableProps<Row extends object> {
33
+ columns: ReadonlyArray<FdyTableColumn<Row>>;
34
+ rows: ReadonlyArray<Row>;
35
+ rowKey: (row: Row) => string | number;
36
+ /** Controlled sort. Provide (even as null) to own sorting; omit for internal client sort. */
37
+ sort?: FdySortState | null;
38
+ onSortChange?: (sort: FdySortState | null) => void;
39
+ /** Controlled filter map keyed by column key. Provide to own filtering; omit for internal. */
40
+ filters?: FdyFilterMap;
41
+ onFiltersChange?: (filters: FdyFilterMap) => void;
42
+ /** Server pagination state (0-based index). Presence switches the table into server mode. */
43
+ page?: FdyPageState;
44
+ onPageChange?: (page: FdyPageState) => void;
45
+ /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
46
+ pageSize?: number;
47
+ loading?: boolean;
48
+ emptyText?: string;
49
+ ariaLabel?: string;
50
+ /** Custom cell renderer; return undefined for a column to use the default text. */
51
+ renderCell?: (column: FdyTableColumn<Row>, row: Row, value: unknown) => ReactNode;
52
+ toolbar?: ReactNode;
53
+ empty?: ReactNode;
54
+ /** Opt in to row activation: rows become focusable and call `onRowActivate` on click/Enter/Space. */
55
+ rowActivatable?: boolean;
56
+ /** Per-row class hook, e.g. to mark a selected row. */
57
+ rowClass?: (row: Row) => string | undefined;
58
+ /** A row was activated (click, or Enter/Space while the row itself is focused). */
59
+ onRowActivate?: (row: Row) => void;
60
+ }
61
+
62
+ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Element {
63
+ const serverPaged: boolean = props.page != null;
64
+ const sortControlled: boolean = serverPaged || props.sort !== undefined;
65
+ const filtersControlled: boolean = serverPaged || props.filters !== undefined;
66
+
67
+ const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
68
+ const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
69
+ const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
70
+
71
+ const effectiveSort: FdySortState | null = sortControlled ? (props.sort ?? null) : internalSort;
72
+ const effectiveFilters: FdyFilterMap = filtersControlled ? (props.filters ?? {}) : internalFilters;
73
+
74
+ // Enum options: explicit (server mode) or distinct values across the current rows (client mode).
75
+ const enumOptionsMap: Record<string, ReadonlyArray<string>> = useMemo(() => {
76
+ const out: Record<string, ReadonlyArray<string>> = {};
77
+ for (const col of props.columns) {
78
+ if (col.filter === 'enum') out[col.key] = col.options ?? distinctValues(props.rows, col);
79
+ }
80
+ return out;
81
+ }, [props.columns, props.rows]);
82
+
83
+ const filteredSorted: Row[] = useMemo(() => {
84
+ if (serverPaged) return props.rows.slice();
85
+ return sortRows(filterRows(props.rows, props.columns, effectiveFilters), props.columns, effectiveSort);
86
+ }, [serverPaged, props.rows, props.columns, effectiveFilters, effectiveSort]);
87
+
88
+ const totalCount: number = serverPaged ? (props.page as FdyPageState).total : filteredSorted.length;
89
+
90
+ const displayRows: Row[] = useMemo(() => {
91
+ if (serverPaged) return props.rows.slice();
92
+ if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted, internalPageIndex, props.pageSize);
93
+ return filteredSorted;
94
+ }, [serverPaged, props.rows, props.pageSize, filteredSorted, internalPageIndex]);
95
+
96
+ const pageSizeEff: number = serverPaged ? (props.page as FdyPageState).size : (props.pageSize ?? 0);
97
+ const currentPage1: number = (serverPaged ? (props.page as FdyPageState).index : internalPageIndex) + 1;
98
+ const totalPages: number = pageSizeEff > 0 ? Math.max(1, Math.ceil(totalCount / pageSizeEff)) : 1;
99
+ const hasPager: boolean = pageSizeEff > 0 && totalPages > 1;
100
+ const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
101
+ const rangeFrom: number = totalCount === 0 ? 0 : (currentPage1 - 1) * pageSizeEff + 1;
102
+ const rangeTo: number = totalCount === 0 ? 0 : rangeFrom - 1 + displayRows.length;
103
+
104
+ // Client mode: keep the page in range when a filter shrinks the row set.
105
+ useEffect((): void => {
106
+ if (!serverPaged && internalPageIndex > totalPages - 1) setInternalPageIndex(Math.max(0, totalPages - 1));
107
+ }, [serverPaged, internalPageIndex, totalPages]);
108
+
109
+ function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
110
+ if (effectiveSort === null || effectiveSort.key !== col.key) return undefined;
111
+ return effectiveSort.dir === 'asc' ? 'ascending' : 'descending';
112
+ }
113
+ function onSort(col: FdyTableColumn<Row>): void {
114
+ if (col.sortable !== true) return;
115
+ const next: FdySortState =
116
+ effectiveSort !== null && effectiveSort.key === col.key
117
+ ? { key: col.key, dir: effectiveSort.dir === 'asc' ? 'desc' : 'asc' }
118
+ : { key: col.key, dir: 'asc' };
119
+ if (sortControlled) props.onSortChange?.(next);
120
+ else {
121
+ setInternalSort(next);
122
+ setInternalPageIndex(0);
123
+ }
124
+ }
125
+ function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
126
+ const nextMap: FdyFilterMap = { ...effectiveFilters };
127
+ if (filter === null) delete nextMap[col.key];
128
+ else nextMap[col.key] = filter;
129
+ if (filtersControlled) props.onFiltersChange?.(nextMap);
130
+ else {
131
+ setInternalFilters(nextMap);
132
+ setInternalPageIndex(0);
133
+ }
134
+ }
135
+ function goTo(page1: number): void {
136
+ const clamped: number = Math.min(Math.max(1, page1), totalPages);
137
+ const index0: number = clamped - 1;
138
+ if (serverPaged) {
139
+ const p: FdyPageState = props.page as FdyPageState;
140
+ props.onPageChange?.({ index: index0, size: p.size, total: p.total });
141
+ } else {
142
+ setInternalPageIndex(index0);
143
+ }
144
+ }
145
+
146
+ function cellClass(col: FdyTableColumn<Row>): string | undefined {
147
+ return col.mono === true ? 'fdy-mono' : undefined;
148
+ }
149
+ function alignStyle(col: FdyTableColumn<Row>): CSSProperties | undefined {
150
+ return col.align !== undefined ? { textAlign: col.align } : undefined;
151
+ }
152
+ function rowClassName(row: Row): string | undefined {
153
+ const cls: string = [props.rowClass?.(row), props.rowActivatable === true ? 'fdy-table__row--activatable' : undefined]
154
+ .filter(Boolean)
155
+ .join(' ');
156
+ return cls === '' ? undefined : cls;
157
+ }
158
+ // Enter/Space activate only when the row itself is focused — a control inside a cell keeps its own
159
+ // event (the `event.target !== event.currentTarget` guard). Click relies on inner controls calling
160
+ // stopPropagation, matching the pattern consumers hand-roll today.
161
+ function onRowKeydown(e: React.KeyboardEvent<HTMLTableRowElement>, row: Row): void {
162
+ if (props.rowActivatable !== true || e.target !== e.currentTarget) return;
163
+ if (e.key !== 'Enter' && e.key !== ' ') return;
164
+ e.preventDefault();
165
+ props.onRowActivate?.(row);
166
+ }
167
+ function renderCellContent(col: FdyTableColumn<Row>, row: Row): ReactNode {
168
+ if (props.renderCell !== undefined) {
169
+ const custom: ReactNode = props.renderCell(col, row, cellValue(row, col));
170
+ if (custom !== undefined) return custom;
171
+ }
172
+ return cellText(row, col);
173
+ }
174
+
175
+ const colCount: number = props.columns.length;
176
+
177
+ return (
178
+ <div className="fdy-datatable">
179
+ {props.toolbar !== undefined && <div className="fdy-table-toolbar">{props.toolbar}</div>}
180
+
181
+ <div className="fdy-table-scroll">
182
+ <table className="fdy-table" aria-label={props.ariaLabel}>
183
+ <thead>
184
+ <tr>
185
+ {props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
186
+ <th key={col.key} scope="col" style={alignStyle(col)} aria-sort={ariaSortOf(col)}>
187
+ {col.sortable ? (
188
+ <button type="button" className="fdy-table__sortbtn" onClick={(): void => onSort(col)}>{col.label}</button>
189
+ ) : (
190
+ col.label
191
+ )}
192
+ {col.filter && (
193
+ <FdyTableFilter
194
+ label={col.label}
195
+ type={col.filter}
196
+ filter={effectiveFilters[col.key]}
197
+ options={enumOptionsMap[col.key] ?? []}
198
+ onChange={(f: FdyColumnFilter | null): void => onFilterChange(col, f)}
199
+ />
200
+ )}
201
+ </th>
202
+ ))}
203
+ </tr>
204
+ </thead>
205
+ <tbody>
206
+ {props.loading ? (
207
+ <tr>
208
+ <td colSpan={colCount} className="fdy-table__state" role="status">Loading…</td>
209
+ </tr>
210
+ ) : displayRows.length === 0 ? (
211
+ <tr>
212
+ <td colSpan={colCount} className="fdy-table__state">{props.empty ?? props.emptyText ?? 'No data'}</td>
213
+ </tr>
214
+ ) : (
215
+ 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>
229
+ ))
230
+ )}
231
+ </tbody>
232
+ </table>
233
+ </div>
234
+
235
+ {hasPager && (
236
+ <div className="fdy-table-footer">
237
+ <span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {totalCount}</span>
238
+ <nav aria-label="Pagination">
239
+ <ul className="fdy-pagination__list">
240
+ <li>
241
+ <button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
242
+ </li>
243
+ {pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
244
+ <li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
245
+ {p === 'ellipsis' ? (
246
+ <span className="fdy-pagination__ellipsis">…</span>
247
+ ) : p === currentPage1 ? (
248
+ <span className="fdy-pagination__link" aria-current="page">{p}</span>
249
+ ) : (
250
+ <button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
251
+ )}
252
+ </li>
253
+ ))}
254
+ <li>
255
+ <button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
256
+ </li>
257
+ </ul>
258
+ </nav>
259
+ </div>
260
+ )}
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,193 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+ import { isFilterActive } from '../../core/table-model.js';
5
+ import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-model.js';
6
+
7
+ // Internal to FdyTable: one column's header funnel button + its type-aware filter popover
8
+ // (text / enum / number / date) over freeday's `.fdy-table__filterbtn` + `.fdy-filter*` classes.
9
+ // React port of adapters/vue/components/FdyTableFilter.vue. Reuses usePopover so the panel escapes
10
+ // the table's `overflow:hidden` via the top layer. Purely controlled — renders the current
11
+ // `filter`, emits the next one (or null to clear); the parent owns where it goes. Not exported.
12
+
13
+ export interface FdyTableFilterProps {
14
+ label: string;
15
+ type: FdyColumnFilterType;
16
+ filter: FdyColumnFilter | undefined;
17
+ /** Distinct values for an enum filter (computed by the parent over the full row set). */
18
+ options: ReadonlyArray<string>;
19
+ /** The next filter for this column, or null to clear it. */
20
+ onChange: (filter: FdyColumnFilter | null) => void;
21
+ }
22
+
23
+ export function FdyTableFilter(props: FdyTableFilterProps): JSX.Element {
24
+ const rootRef = useRef<HTMLSpanElement>(null);
25
+ const triggerRef = useRef<HTMLButtonElement>(null);
26
+ const panelRef = useRef<HTMLDivElement>(null);
27
+ const [open, setOpen] = useState<boolean>(false);
28
+
29
+ usePopover(panelRef, triggerRef, open);
30
+ // Popover attr for React 18/19 JSX typing: set once on mount (same as the other components).
31
+ useEffect((): void => {
32
+ panelRef.current?.setAttribute('popover', 'manual');
33
+ }, []);
34
+
35
+ const active: boolean = isFilterActive(props.filter);
36
+ const filter: FdyColumnFilter | undefined = props.filter;
37
+ const textValue: string = filter?.type === 'text' ? filter.text : '';
38
+ const enumValues: ReadonlyArray<string> = filter?.type === 'enum' ? filter.values : [];
39
+ const numMin: string = filter?.type === 'number' && filter.min !== null ? String(filter.min) : '';
40
+ const numMax: string = filter?.type === 'number' && filter.max !== null ? String(filter.max) : '';
41
+ const dateFrom: string = filter?.type === 'date' && filter.from !== null ? filter.from : '';
42
+ const dateTo: string = filter?.type === 'date' && filter.to !== null ? filter.to : '';
43
+
44
+ function apply(next: FdyColumnFilter): void {
45
+ props.onChange(isFilterActive(next) ? next : null);
46
+ }
47
+ function parseNum(v: string): number | null {
48
+ const t: string = v.trim();
49
+ if (t === '') return null;
50
+ const n: number = Number(t);
51
+ return Number.isNaN(n) ? null : n;
52
+ }
53
+ function onEnumToggle(value: string, checked: boolean): void {
54
+ const set: string[] = enumValues.filter((v: string): boolean => v !== value);
55
+ if (checked) set.push(value);
56
+ apply({ type: 'enum', values: set });
57
+ }
58
+ function close(returnFocus: boolean): void {
59
+ setOpen(false);
60
+ if (returnFocus) triggerRef.current?.focus();
61
+ }
62
+ function reset(): void {
63
+ props.onChange(null);
64
+ close(true);
65
+ }
66
+
67
+ // Dismiss on outside pointer or Escape while open.
68
+ useEffect((): void | (() => void) => {
69
+ if (!open) return;
70
+ function onPointerDown(e: MouseEvent): void {
71
+ const t: EventTarget | null = e.target;
72
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) close(false);
73
+ }
74
+ function onKeydown(e: KeyboardEvent): void {
75
+ if (e.key === 'Escape') close(true);
76
+ }
77
+ document.addEventListener('mousedown', onPointerDown);
78
+ document.addEventListener('keydown', onKeydown);
79
+ return (): void => {
80
+ document.removeEventListener('mousedown', onPointerDown);
81
+ document.removeEventListener('keydown', onKeydown);
82
+ };
83
+ }, [open]);
84
+
85
+ return (
86
+ <span ref={rootRef} className="fdy-table__filterwrap">
87
+ <button
88
+ ref={triggerRef}
89
+ type="button"
90
+ className={active ? 'fdy-table__filterbtn is-active' : 'fdy-table__filterbtn'}
91
+ aria-haspopup="dialog"
92
+ aria-pressed={active}
93
+ aria-expanded={open}
94
+ aria-label={`Filter ${props.label}`}
95
+ onClick={(e): void => {
96
+ e.stopPropagation();
97
+ setOpen((v: boolean): boolean => !v);
98
+ }}
99
+ >
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
101
+ <path d="M3 5h18l-7 8v5l-4 2v-7z" />
102
+ </svg>
103
+ </button>
104
+
105
+ <div ref={panelRef} className="fdy-filter" hidden={!open} role="dialog" aria-label={`Filter ${props.label}`}>
106
+ {props.type === 'text' && (
107
+ <>
108
+ <div className="fdy-filter__title">Contains text</div>
109
+ <input
110
+ className="fdy-input"
111
+ type="search"
112
+ placeholder="Contains…"
113
+ value={textValue}
114
+ onChange={(e): void => apply({ type: 'text', text: e.target.value })}
115
+ />
116
+ </>
117
+ )}
118
+
119
+ {props.type === 'enum' && (
120
+ <>
121
+ <div className="fdy-filter__title">Show values</div>
122
+ <div className="fdy-filter__list">
123
+ {props.options.map((val: string): JSX.Element => (
124
+ <label key={val} className="fdy-filter__check">
125
+ <input
126
+ type="checkbox"
127
+ className="fdy-checkbox"
128
+ checked={enumValues.includes(val)}
129
+ onChange={(e): void => onEnumToggle(val, e.target.checked)}
130
+ />
131
+ {val}
132
+ </label>
133
+ ))}
134
+ </div>
135
+ </>
136
+ )}
137
+
138
+ {props.type === 'number' && (
139
+ <>
140
+ <div className="fdy-filter__title">Value range</div>
141
+ <div className="fdy-filter__range">
142
+ <input
143
+ className="fdy-input"
144
+ type="text"
145
+ inputMode="numeric"
146
+ placeholder="Min"
147
+ value={numMin}
148
+ onChange={(e): void => apply({ type: 'number', min: parseNum(e.target.value), max: parseNum(numMax) })}
149
+ />
150
+ <span aria-hidden="true">–</span>
151
+ <input
152
+ className="fdy-input"
153
+ type="text"
154
+ inputMode="numeric"
155
+ placeholder="Max"
156
+ value={numMax}
157
+ onChange={(e): void => apply({ type: 'number', min: parseNum(numMin), max: parseNum(e.target.value) })}
158
+ />
159
+ </div>
160
+ </>
161
+ )}
162
+
163
+ {props.type === 'date' && (
164
+ <>
165
+ <div className="fdy-filter__title">Date range</div>
166
+ <div className="fdy-filter__range">
167
+ <input
168
+ className="fdy-input"
169
+ type="date"
170
+ aria-label="From"
171
+ value={dateFrom}
172
+ onChange={(e): void => apply({ type: 'date', from: e.target.value || null, to: dateTo || null })}
173
+ />
174
+ <span aria-hidden="true">–</span>
175
+ <input
176
+ className="fdy-input"
177
+ type="date"
178
+ aria-label="To"
179
+ value={dateTo}
180
+ onChange={(e): void => apply({ type: 'date', from: dateFrom || null, to: e.target.value || null })}
181
+ />
182
+ </div>
183
+ </>
184
+ )}
185
+
186
+ <div className="fdy-filter__foot">
187
+ <button type="button" className="fdy-btn fdy-btn--ghost fdy-btn--sm" onClick={reset}>Reset</button>
188
+ <button type="button" className="fdy-btn fdy-btn--sm" onClick={(): void => close(true)}>Close</button>
189
+ </div>
190
+ </div>
191
+ </span>
192
+ );
193
+ }
@@ -35,3 +35,19 @@ export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAuto
35
35
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
36
36
  export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
37
37
  export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
38
+ export { FdyTable, type FdyTableProps } from './components/FdyTable';
39
+ export { FdyModal, type FdyModalProps } from './components/FdyModal';
40
+ export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
41
+
42
+ /** Controlled data-table types (shared, framework-agnostic core). */
43
+ export type {
44
+ FdyTableColumn,
45
+ FdySortState,
46
+ FdySortDir,
47
+ FdyColumnType,
48
+ FdyColumnAlign,
49
+ FdyColumnFilterType,
50
+ FdyColumnFilter,
51
+ FdyFilterMap,
52
+ FdyPageState,
53
+ } from '../core/table-model';
@@ -7,3 +7,6 @@ export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
7
7
  export { FdyCascade } from './components/FdyCascade.tsx';
8
8
  export { FdyCfl } from './components/FdyCfl.tsx';
9
9
  export { FdyChart } from './components/FdyChart.tsx';
10
+ export { FdyTable } from './components/FdyTable.tsx';
11
+ export { FdyModal } from './components/FdyModal.tsx';
12
+ export { FdyDrawer } from './components/FdyDrawer.tsx';
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
3
+
4
+ // A controlled Vue wrapper over freeday's `.fdy-drawer` native <dialog> side panel
5
+ // (src/components/drawer.css). Same controlled contract and glue as FdyModal — showModal()/close()
6
+ // guarded, @cancel.prevent so Esc routes through app state, backdrop-click via `event.target ===
7
+ // dialogEl` — applied to a drawer that anchors left (default) or right. Native <dialog> supplies the
8
+ // focus trap, focus restore, top-layer stacking and inert background; `dismissible` (default true)
9
+ // gates Esc + backdrop dismissal.
10
+
11
+ const props = defineProps<{
12
+ open: boolean;
13
+ title: string;
14
+ side?: 'left' | 'right';
15
+ dismissible?: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{
19
+ close: [];
20
+ }>();
21
+
22
+ const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
23
+ const titleId: string = `${useId()}-title`;
24
+ const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
25
+ const drawerClass: ComputedRef<string> = computed((): string =>
26
+ props.side === 'right' ? 'fdy-drawer fdy-drawer--right' : 'fdy-drawer',
27
+ );
28
+
29
+ function sync(open: boolean): void {
30
+ const el: HTMLDialogElement | null = dialogEl.value;
31
+ if (el === null) return;
32
+ if (open && !el.open) el.showModal();
33
+ else if (!open && el.open) el.close();
34
+ }
35
+ watch((): boolean => props.open, sync, { flush: 'post' });
36
+ onMounted((): void => sync(props.open));
37
+
38
+ function onCancel(): void {
39
+ if (dismissible.value) emit('close');
40
+ }
41
+ function onClick(e: MouseEvent): void {
42
+ if (dismissible.value && e.target === dialogEl.value) emit('close');
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <dialog
48
+ ref="dialogEl"
49
+ :class="drawerClass"
50
+ :aria-labelledby="titleId"
51
+ @cancel.prevent="onCancel"
52
+ @click="onClick"
53
+ >
54
+ <div class="fdy-drawer__header">
55
+ <h3 :id="titleId" class="fdy-drawer__title">
56
+ <slot name="title">{{ title }}</slot>
57
+ </h3>
58
+ <button v-if="dismissible" class="fdy-drawer__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
59
+ </div>
60
+
61
+ <div class="fdy-drawer__body">
62
+ <slot />
63
+ </div>
64
+
65
+ <div v-if="$slots.footer" class="fdy-drawer__footer">
66
+ <slot name="footer" />
67
+ </div>
68
+ </dialog>
69
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
3
+
4
+ // A controlled Vue wrapper over freeday's `.fdy-modal` native <dialog> (src/components/modal.css).
5
+ // The kit styles the dialog but nothing drives it; every consumer re-derives the same imperative
6
+ // glue to reconcile a reactive `open` boolean with a DOM element whose open/close is a method call.
7
+ // This writes that glue once: showModal()/close() guarded against the already-open/closed cases
8
+ // (showModal() on an open dialog throws), @cancel.prevent so Esc routes through app state instead of
9
+ // closing the DOM behind its back, and backdrop-click detection via `event.target === dialogEl`.
10
+ // Native <dialog> already provides the focus trap, focus restore, top-layer stacking and inert
11
+ // background — the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
12
+
13
+ const props = defineProps<{
14
+ open: boolean;
15
+ title: string;
16
+ size?: 'sm' | 'md' | 'lg' | 'wide';
17
+ dismissible?: boolean;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ close: [];
22
+ }>();
23
+
24
+ const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
25
+ const titleId: string = `${useId()}-title`;
26
+ const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
27
+ const modalClass: ComputedRef<string> = computed((): string =>
28
+ props.size !== undefined ? `fdy-modal fdy-modal--${props.size}` : 'fdy-modal',
29
+ );
30
+
31
+ // Reconcile the reactive `open` with the dialog's method-driven state. Both guards matter:
32
+ // showModal() on an already-open dialog throws; close() on a closed one is a no-op but kept symmetric.
33
+ function sync(open: boolean): void {
34
+ const el: HTMLDialogElement | null = dialogEl.value;
35
+ if (el === null) return;
36
+ if (open && !el.open) el.showModal();
37
+ else if (!open && el.open) el.close();
38
+ }
39
+ watch((): boolean => props.open, sync, { flush: 'post' });
40
+ onMounted((): void => sync(props.open));
41
+
42
+ // Esc fires `cancel`; .prevent stops the native close so app state stays the single source of truth.
43
+ function onCancel(): void {
44
+ if (dismissible.value) emit('close');
45
+ }
46
+ // The ::backdrop is not a separate element — a click whose target is the dialog box itself (not its
47
+ // content) is a backdrop click.
48
+ function onClick(e: MouseEvent): void {
49
+ if (dismissible.value && e.target === dialogEl.value) emit('close');
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <dialog
55
+ ref="dialogEl"
56
+ :class="modalClass"
57
+ :aria-labelledby="titleId"
58
+ @cancel.prevent="onCancel"
59
+ @click="onClick"
60
+ >
61
+ <div class="fdy-modal__header">
62
+ <h3 :id="titleId" class="fdy-modal__title">
63
+ <slot name="title">{{ title }}</slot>
64
+ </h3>
65
+ <button v-if="dismissible" class="fdy-modal__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
66
+ </div>
67
+
68
+ <div class="fdy-modal__body">
69
+ <slot />
70
+ </div>
71
+
72
+ <div v-if="$slots.footer" class="fdy-modal__footer">
73
+ <slot name="footer" />
74
+ </div>
75
+ </dialog>
76
+ </template>