@kubex/zinc 1.1.121 → 1.1.123

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.
Files changed (37) hide show
  1. package/dist/custom-elements.json +2847 -1816
  2. package/dist/vscode.html-custom-data.json +189 -167
  3. package/dist/web-types.json +531 -465
  4. package/dist/zn.d.ts +401 -296
  5. package/dist/zn.min.js +1478 -1337
  6. package/docs/pages/components/button.md +9 -0
  7. package/docs/pages/components/data-table-filter.md +60 -4
  8. package/docs/pages/components/data-table-search.md +3 -3
  9. package/docs/pages/components/data-table.md +78 -9
  10. package/docs/pages/components/datepicker.md +10 -0
  11. package/docs/pages/components/menu-item.md +16 -2
  12. package/package.json +1 -1
  13. package/src/components/button/button.component.ts +11 -4
  14. package/src/components/button/button.scss +12 -1
  15. package/src/components/data-table/data-table.component.ts +341 -139
  16. package/src/components/data-table/data-table.scss +110 -43
  17. package/src/components/data-table/data-table.test.ts +110 -0
  18. package/src/components/data-table-filter/data-table-filter.component.ts +487 -49
  19. package/src/components/data-table-filter/data-table-filter.scss +163 -9
  20. package/src/components/data-table-filter/data-table-filter.test.ts +328 -2
  21. package/src/components/data-table-search/data-table-search.component.ts +1 -1
  22. package/src/components/data-table-search/data-table-search.scss +5 -0
  23. package/src/components/datepicker/datepicker.component.ts +23 -2
  24. package/src/components/datepicker/datepicker.scss +25 -0
  25. package/src/components/datepicker/datepicker.test.ts +28 -1
  26. package/src/components/input/input.component.ts +1 -0
  27. package/src/components/input/input.scss +1 -1
  28. package/src/components/menu/menu.component.ts +2 -0
  29. package/src/components/menu/menu.scss +23 -2
  30. package/src/components/menu-item/menu-item.component.ts +7 -1
  31. package/src/components/menu-item/menu-item.scss +4 -4
  32. package/src/components/menu-item/menu-item.test.ts +39 -1
  33. package/src/components/panel/panel.component.ts +12 -5
  34. package/src/components/panel/panel.scss +3 -3
  35. package/src/components/query-builder/query-builder.component.ts +1 -1
  36. package/src/components/tooltip/tooltip.component.ts +2 -2
  37. package/src/components/tooltip/tooltip.test.ts +70 -2
@@ -1,22 +1,176 @@
1
1
  @use "../../wc";
2
2
 
3
+ // Fields inside a panel that already draws its own edge
4
+ @mixin flush-input {
5
+ --zn-input-box-shadow: none;
6
+ --zn-input-background-color: transparent;
7
+ --zn-input-background-color-hover: transparent;
8
+ --zn-input-background-color-focus: transparent;
9
+ --zn-input-border-color: transparent;
10
+ --zn-input-border-color-hover: transparent;
11
+ --zn-input-border-color-focus: transparent;
12
+ --zn-input-focus-ring-color: transparent;
13
+ }
14
+
3
15
  :host {
4
16
  display: block;
5
17
  }
6
18
 
7
- .data-table-filter {
19
+ .filter-bar {
8
20
  display: flex;
9
- flex-direction: column;
10
- gap: var(--zn-spacing-small);
21
+ flex-direction: row;
22
+ align-items: center;
23
+ flex-wrap: wrap;
24
+ gap: var(--zn-spacing-x-small);
25
+
26
+ &__clear::part(base) {
27
+ height: 32px;
28
+ min-height: 32px;
29
+ padding: 0 var(--zn-spacing-x-small);
30
+ font-weight: var(--zn-font-weight-normal);
31
+ text-transform: none;
32
+ white-space: nowrap;
33
+ }
34
+
35
+ &__clear::part(base) {
36
+ color: rgb(var(--zn-primary));
37
+ }
38
+
39
+ // The chip draws the outline so its trigger and remove button read as one control
40
+ &__chip {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ height: 32px;
44
+ padding-right: var(--zn-spacing-x-small);
45
+ border: 1px solid rgb(var(--zn-border-color));
46
+ border-radius: var(--zn-border-radius-pill);
47
+ background-color: rgb(var(--zn-panel));
48
+
49
+ &:hover {
50
+ background-color: rgba(var(--zn-color-tab), 0.5);
51
+ }
52
+ }
53
+
54
+ // The X's own box carries the trailing gap, so the trigger adds none
55
+ &__pill::part(base),
56
+ &__pill::part(base):hover {
57
+ border-color: transparent;
58
+ background: transparent;
59
+ padding-right: 0;
60
+ }
61
+
62
+ // Same box as the trigger, so the X centres on the chip's axis rather than
63
+ // inside a smaller box centred within it. The hover tint is clipped to the
64
+ // padded content box so it reads as a disc on the icon, not a full-height bar
65
+ &__remove::part(base) {
66
+ width: 24px;
67
+ min-width: 24px;
68
+ height: 32px;
69
+ min-height: 32px;
70
+ max-height: 32px;
71
+ padding: 4px 0;
72
+ background: transparent;
73
+ background-clip: content-box;
74
+ border-radius: var(--zn-border-radius-circle);
75
+ color: var(--zn-text-color);
76
+ }
11
77
 
12
- &__item {
78
+ &__remove::part(base):hover {
79
+ background: rgb(var(--zn-color-border));
80
+ background-clip: content-box;
81
+ }
82
+
83
+ // Value editors get their own panel: a calendar has no business inside a menu's
84
+ // scroll container. The panel draws the only edge, so the editor inside it runs
85
+ // flush to it
86
+ &__editor {
13
87
  display: flex;
14
- justify-content: space-between;
88
+ flex-direction: column;
89
+ align-items: stretch;
90
+ overflow: hidden;
91
+ border: 1px solid rgb(var(--zn-border-color));
92
+ border-radius: var(--zn-border-radius);
93
+ background-color: rgb(var(--zn-panel));
94
+ filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.04)) drop-shadow(0 4px 3px rgba(0, 0, 0, 0.1));
95
+
96
+ zn-datepicker {
97
+ --zn-datepicker-inline-border-color: transparent;
98
+ }
15
99
 
16
- &__name {
17
- font-weight: 500;
18
- color: rgb(var(--zn-text-heading));
19
- width: 100%;
100
+ zn-input {
101
+ @include flush-input;
102
+ min-width: 200px;
20
103
  }
21
104
  }
105
+
106
+ // A long option list has to scroll rather than run off the screen, and its search
107
+ // field stays put while it does
108
+ zn-menu {
109
+ --zn-menu-max-height: 320px;
110
+ }
111
+
112
+ // Full-bleed so the rows scrolling underneath never show around or above it
113
+ &__option-search {
114
+ @include flush-input;
115
+ position: sticky;
116
+ top: calc(-1 * var(--zn-spacing-2x-small));
117
+ z-index: 1;
118
+ margin: calc(-1 * var(--zn-spacing-2x-small)) calc(-1 * var(--zn-spacing-2x-small)) 0;
119
+ background-color: rgb(var(--zn-panel));
120
+ border-bottom: 1px solid rgb(var(--zn-border-color));
121
+ }
122
+
123
+ &__suggestions {
124
+ display: flex;
125
+ flex-direction: column;
126
+ align-items: stretch;
127
+ max-height: 220px;
128
+ overflow-y: auto;
129
+ border-top: 1px solid rgb(var(--zn-border-color));
130
+ @include wc.scrollbars;
131
+ }
132
+
133
+ &__suggestion::part(base) {
134
+ justify-content: flex-start;
135
+ padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
136
+ font-weight: var(--zn-font-weight-normal);
137
+ text-transform: none;
138
+ color: rgb(var(--zn-text));
139
+ text-align: left;
140
+ }
141
+
142
+ // Segmented operator picker at the top of a value editor
143
+ &__operators {
144
+ display: flex;
145
+ flex-wrap: wrap;
146
+ gap: var(--zn-spacing-2x-small);
147
+ padding: var(--zn-spacing-x-small);
148
+ border-bottom: 1px solid rgb(var(--zn-border-color));
149
+ }
150
+
151
+ &__operator::part(base) {
152
+ height: 26px;
153
+ min-height: 26px;
154
+ min-width: 30px;
155
+ padding: 0 var(--zn-spacing-x-small);
156
+ font-size: var(--zn-font-size-small);
157
+ font-weight: var(--zn-font-weight-normal);
158
+ text-transform: none;
159
+ color: rgb(var(--zn-text));
160
+ background-color: transparent;
161
+ border-color: rgb(var(--zn-border-color));
162
+ }
163
+
164
+ &__operator--selected::part(base) {
165
+ color: rgb(var(--zn-primary));
166
+ border-color: rgb(var(--zn-primary));
167
+ background-color: rgba(var(--zn-primary), 0.05);
168
+ }
169
+
170
+ &__separator {
171
+ flex: none;
172
+ height: 1px;
173
+ margin: var(--zn-spacing-2x-small) 0;
174
+ background-color: rgb(var(--zn-border-color));
175
+ }
22
176
  }
@@ -1,10 +1,336 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import type ZnDataTableFilter from './data-table-filter.component';
4
+ import type ZnDatepicker from '../datepicker/datepicker.component';
5
+ import type ZnDropdown from '../dropdown/dropdown.component';
6
+ import type ZnInput from '../input/input.component';
7
+
8
+ const FILTERS = [
9
+ {id: 'role', name: 'role', operators: ['in'], options: {pm: 'Project Manager', eng: 'Engineer'}},
10
+ {id: 'status', name: 'status', operators: ['eq'], options: {active: 'Active', inactive: 'Inactive'}},
11
+ {id: 'email', name: 'email', operators: ['contains']},
12
+ {id: 'created', name: 'created', type: 'date', operators: ['eq'], dateSubmitFormat: 'timestamp'},
13
+ {id: 'age', name: 'age', type: 'number', operators: ['eq', 'gte']}
14
+ ];
15
+
16
+ const clickOn = (el: Element | null | undefined) =>
17
+ el?.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
18
+
19
+ const decode = (value: string) => JSON.parse(atob(value)) as { key: string; comparator: string; value: string }[];
20
+
21
+ async function filterBar(defaultFilters = '') {
22
+ const el = await fixture<ZnDataTableFilter>(html`
23
+ <zn-data-table-filter default-filters="${defaultFilters}" .filters="${FILTERS}"></zn-data-table-filter>`);
24
+ await el.updateComplete;
25
+ return el;
26
+ }
27
+
28
+ const pills = (el: ZnDataTableFilter) =>
29
+ [...el.shadowRoot!.querySelectorAll('.filter-bar__pill')].map(p => p.textContent!.trim());
3
30
 
4
31
  describe('<zn-data-table-filter>', () => {
5
32
  it('should render a component', async () => {
6
- const el = await fixture(html` <zn-data-table-filter></zn-data-table-filter> `);
33
+ const el = await fixture(html`
34
+ <zn-data-table-filter></zn-data-table-filter>`);
7
35
 
8
36
  expect(el).to.exist;
9
37
  });
38
+
39
+ it('renders a pill for each default filter', async () => {
40
+ const el = await filterBar('role');
41
+
42
+ expect(pills(el)).to.deep.equal(['Role']);
43
+ });
44
+
45
+ it('offers the filters that are not already active', async () => {
46
+ const el = await filterBar('role');
47
+
48
+ const options = [...el.shadowRoot!.querySelectorAll('.filter-bar__add + zn-menu zn-menu-item, zn-menu zn-menu-item')]
49
+ .map(i => i.getAttribute('value'));
50
+
51
+ expect(options).to.include('status');
52
+ expect(options).to.include('email');
53
+ });
54
+
55
+ it('encodes a selected value as the query-builder payload', async () => {
56
+ const el = await filterBar('status');
57
+
58
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
59
+ await el.updateComplete;
60
+
61
+ expect(decode(el.value)).to.deep.equal([{key: 'status', comparator: 'eq', value: 'active'}]);
62
+ });
63
+
64
+ it('accumulates values for an `in` filter and counts them on the pill', async () => {
65
+ const el = await filterBar('role');
66
+
67
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="pm"]'));
68
+ await el.updateComplete;
69
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="eng"]'));
70
+ await el.updateComplete;
71
+
72
+ expect(decode(el.value)[0].value).to.equal('pm eng');
73
+ expect(pills(el)).to.deep.equal(['Role (2)']);
74
+ });
75
+
76
+ it('unsets a single-value filter when its selected option is picked again', async () => {
77
+ const el = await filterBar('status');
78
+
79
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
80
+ await el.updateComplete;
81
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
82
+ await el.updateComplete;
83
+
84
+ expect(el.value).to.equal('');
85
+ });
86
+
87
+ it('leaves valueless filters out of the payload', async () => {
88
+ const el = await filterBar('role,status');
89
+
90
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
91
+ await el.updateComplete;
92
+
93
+ expect(decode(el.value)).to.deep.equal([{key: 'status', comparator: 'eq', value: 'active'}]);
94
+ });
95
+
96
+ it('emits zn-filter-change when a value changes', async () => {
97
+ const el = await filterBar('status');
98
+ let emitted = 0;
99
+ el.addEventListener('zn-filter-change', () => emitted++);
100
+
101
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
102
+ await waitUntil(() => emitted > 0);
103
+
104
+ expect(emitted).to.equal(1);
105
+ });
106
+
107
+ it('keeps a multi-value filter open on select and closes a single-value one', async () => {
108
+ const el = await filterBar('role,status');
109
+ const dropdowns = [...el.shadowRoot!.querySelectorAll<ZnDropdown>('zn-dropdown')];
110
+
111
+ const multi = dropdowns.find(d => d.querySelector('zn-menu-item[value="pm"]'))!;
112
+ const single = dropdowns.find(d => d.querySelector('zn-menu-item[value="active"]'))!;
113
+
114
+ expect(multi.stayOpenOnSelect).to.be.true;
115
+ expect(single.stayOpenOnSelect).to.be.false;
116
+ });
117
+
118
+ it('counts a typed value as one, however many spaces it contains', async () => {
119
+ const el = await filterBar('email');
120
+
121
+ const input = el.shadowRoot!.querySelector<ZnInput>('zn-input')!;
122
+ input.value = 'thats really bad';
123
+ input.dispatchEvent(new Event('zn-input', {bubbles: true, composed: true}));
124
+ await waitUntil(() => el.value.length > 0);
125
+ await el.updateComplete;
126
+
127
+ expect(pills(el)).to.deep.equal(['Email: thats really bad']);
128
+ });
129
+
130
+ it('matches suggestions without regard to case', async () => {
131
+ const el = await filterBar('email');
132
+ el.suggestions = {email: ['Ada@example.com', 'grace@example.com']};
133
+
134
+ const input = el.shadowRoot!.querySelector<ZnInput>('zn-input')!;
135
+ input.value = 'ADA';
136
+ input.dispatchEvent(new Event('zn-input', {bubbles: true, composed: true}));
137
+ await waitUntil(() => el.shadowRoot!.querySelectorAll('.filter-bar__suggestion').length > 0);
138
+
139
+ const shown = [...el.shadowRoot!.querySelectorAll('.filter-bar__suggestion')].map(b => b.textContent!.trim());
140
+
141
+ expect(shown).to.deep.equal(['Ada@example.com']);
142
+ });
143
+
144
+ it('renders a text input for a filter with no options', async () => {
145
+ const el = await filterBar('email');
146
+
147
+ expect(el.shadowRoot!.querySelector('.filter-bar__editor zn-input')).to.exist;
148
+ });
149
+
150
+ it('renders an inline calendar for a date filter', async () => {
151
+ const el = await filterBar('created');
152
+
153
+ const picker = el.shadowRoot!.querySelector('.filter-bar__editor zn-datepicker');
154
+
155
+ expect(picker).to.exist;
156
+ expect(picker!.hasAttribute('inline')).to.be.true;
157
+ });
158
+
159
+ it('encodes a picked date and labels the pill with it', async () => {
160
+ const el = await filterBar('created');
161
+ const picker = el.shadowRoot!.querySelector<ZnDatepicker>('zn-datepicker')!;
162
+ await picker.updateComplete;
163
+
164
+ picker.shadowRoot!.querySelector<HTMLElement>('.air-datepicker-cell.-day-:not(.-other-month-)')!.click();
165
+ // AirDatepicker reports a selection on the next task, not synchronously
166
+ await waitUntil(() => el.value.length > 0);
167
+ await el.updateComplete;
168
+
169
+ const [filter] = decode(el.value);
170
+
171
+ expect(filter.key).to.equal('created');
172
+ expect(filter.comparator).to.equal('eq');
173
+ expect(Number(filter.value)).to.be.greaterThan(0);
174
+ expect(pills(el)[0]).to.equal(`Created: ${picker.value as string}`);
175
+ });
176
+
177
+ it('offers each operator when a filter defines more than one', async () => {
178
+ const el = await filterBar('age');
179
+
180
+ const operators = [...el.shadowRoot!.querySelectorAll('.filter-bar__operator')].map(o => o.textContent!.trim());
181
+
182
+ expect(operators).to.deep.equal(['is', '\u2265']);
183
+ });
184
+
185
+ it('re-encodes the value with the operator the user picks', async () => {
186
+ const el = await filterBar('age');
187
+
188
+ const input = el.shadowRoot!.querySelector<ZnInput>('zn-input')!;
189
+ input.value = '18';
190
+ input.dispatchEvent(new Event('zn-input', {bubbles: true, composed: true}));
191
+ await waitUntil(() => el.value.length > 0);
192
+
193
+ clickOn(el.shadowRoot!.querySelectorAll('.filter-bar__operator')[1]);
194
+ await el.updateComplete;
195
+
196
+ expect(decode(el.value)).to.deep.equal([{key: 'age', comparator: 'gte', value: '18'}]);
197
+ expect(pills(el)).to.deep.equal(['Age \u2265 18']);
198
+ });
199
+
200
+ it('lists operators above the options for a filter that has both', async () => {
201
+ const el = await fixture<ZnDataTableFilter>(html`
202
+ <zn-data-table-filter default-filters="state"
203
+ .filters="${[{
204
+ id: 'state',
205
+ name: 'state',
206
+ operators: ['eq', 'in'],
207
+ options: {open: 'Open', closed: 'Closed'}
208
+ }]}"></zn-data-table-filter>`);
209
+ await el.updateComplete;
210
+
211
+ const items = [...el.shadowRoot!.querySelectorAll('zn-menu-item')].map(i => i.textContent!.trim());
212
+
213
+ expect(items).to.deep.equal(['is', 'is any of', 'Open', 'Closed']);
214
+ });
215
+
216
+ it('opens the new pill so its value can be set without a second click', async () => {
217
+ const el = await filterBar();
218
+
219
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="status"]'));
220
+ await el.updateComplete;
221
+
222
+ const pill = [...el.shadowRoot!.querySelectorAll<ZnDropdown>('zn-dropdown')]
223
+ .find(dropdown => dropdown.dataset.key === 'status')!;
224
+
225
+ await waitUntil(() => pill.open, 'the new filter\'s panel never opened');
226
+ expect(pill.open).to.be.true;
227
+ });
228
+
229
+ it('keeps the panel open for an operator but closes it for a value', async () => {
230
+ const el = await fixture<ZnDataTableFilter>(html`
231
+ <zn-data-table-filter default-filters="state"
232
+ .filters="${[{
233
+ id: 'state',
234
+ name: 'state',
235
+ operators: ['eq', 'in'],
236
+ options: {open: 'Open', closed: 'Closed'}
237
+ }]}"></zn-data-table-filter>`);
238
+ await el.updateComplete;
239
+
240
+ const pill = el.shadowRoot!.querySelector<ZnDropdown>('zn-dropdown')!;
241
+ await pill.show();
242
+
243
+ const [operator] = [...pill.querySelectorAll('zn-menu-item')];
244
+ clickOn(operator);
245
+ await el.updateComplete;
246
+ expect(pill.open, 'picking an operator should leave the panel open').to.be.true;
247
+
248
+ clickOn(pill.querySelector('zn-menu-item[value="open"]'));
249
+ await waitUntil(() => !pill.open, 'picking a value should close the panel');
250
+ expect(pill.open).to.be.false;
251
+ });
252
+
253
+ describe('long option lists', () => {
254
+ const many = Object.fromEntries(
255
+ ['How To', 'Linux', 'Beta', 'Router', 'TV', 'Other', 'Mac Hotspot Shield', 'General', 'iOS', 'Tutorials']
256
+ .map((label, index) => [`c${index}`, label])
257
+ );
258
+
259
+ async function categoryBar() {
260
+ const el = await fixture<ZnDataTableFilter>(html`
261
+ <zn-data-table-filter default-filters="category"
262
+ .filters="${[{
263
+ id: 'category',
264
+ name: 'category',
265
+ operators: ['eq'],
266
+ options: many
267
+ }]}"></zn-data-table-filter>`);
268
+ await el.updateComplete;
269
+ return el;
270
+ }
271
+
272
+ const search = async (el: ZnDataTableFilter, term: string) => {
273
+ const input = el.shadowRoot!.querySelector<ZnInput>('.filter-bar__option-search')!;
274
+ input.value = term;
275
+ input.dispatchEvent(new Event('zn-input', {bubbles: true, composed: true}));
276
+ await el.updateComplete;
277
+ };
278
+
279
+ const labels = (el: ZnDataTableFilter) =>
280
+ [...el.shadowRoot!.querySelectorAll('zn-menu-item')].map(item => item.textContent!.trim());
281
+
282
+ it('offers a search above the options', async () => {
283
+ const el = await categoryBar();
284
+
285
+ expect(el.shadowRoot!.querySelector('.filter-bar__option-search')).to.exist;
286
+ });
287
+
288
+ it('leaves the search off a short list', async () => {
289
+ const el = await filterBar('status');
290
+
291
+ expect(el.shadowRoot!.querySelector('.filter-bar__option-search')).to.not.exist;
292
+ });
293
+
294
+ it('narrows the options without regard to case', async () => {
295
+ const el = await categoryBar();
296
+
297
+ await search(el, 'HOTSPOT');
298
+
299
+ expect(labels(el)).to.deep.equal(['Mac Hotspot Shield']);
300
+ });
301
+
302
+ it('says so when nothing matches', async () => {
303
+ const el = await categoryBar();
304
+
305
+ await search(el, 'nothing here');
306
+
307
+ expect(labels(el)).to.deep.equal(['No matches']);
308
+ });
309
+ });
310
+
311
+ it('drops a filter when its chip\'s remove button is clicked', async () => {
312
+ const el = await filterBar('role,status');
313
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
314
+ await el.updateComplete;
315
+
316
+ const chip = [...el.shadowRoot!.querySelectorAll('.filter-bar__chip')]
317
+ .find(item => item.querySelector('zn-dropdown')!.getAttribute('data-key') === 'status')!;
318
+ clickOn(chip.querySelector('.filter-bar__remove'));
319
+ await el.updateComplete;
320
+
321
+ expect(pills(el)).to.deep.equal(['Role']);
322
+ expect(el.value).to.equal('');
323
+ });
324
+
325
+ it('clear() removes every pill and empties the value', async () => {
326
+ const el = await filterBar('role,status');
327
+ clickOn(el.shadowRoot!.querySelector('zn-menu-item[value="active"]'));
328
+ await el.updateComplete;
329
+
330
+ el.clear();
331
+ await el.updateComplete;
332
+
333
+ expect(pills(el)).to.deep.equal([]);
334
+ expect(el.value).to.equal('');
335
+ });
10
336
  });
@@ -187,7 +187,7 @@ export default class ZnDataTableSearch extends ZincElement implements ZincFormCo
187
187
  clearable
188
188
  @zn-input="${this.handleInput}"
189
189
  @zn-clear="${this.handleClear}">
190
- <zn-icon slot="prefix" src="search" size="20"></zn-icon>
190
+ <zn-icon slot="suffix" src="search@lu" size="20"></zn-icon>
191
191
  </zn-input>
192
192
  <slot style="display: none"></slot>
193
193
  </div>
@@ -11,6 +11,11 @@
11
11
  min-width: 200px;
12
12
  }
13
13
 
14
+ // Flush so the input box is exactly the 36px control height the toolbar tops align on
15
+ zn-input::part(form-control-input) {
16
+ margin: 0;
17
+ }
18
+
14
19
  zn-input::part(form-control-help-text) {
15
20
  font-size: var(--zn-input-help-text-font-size-small);
16
21
  padding-left: var(--zn-spacing-2x-small);
@@ -53,6 +53,7 @@ import styles from './datepicker.scss';
53
53
  * - yyyy/MM/dd (2024/12/31)
54
54
  *
55
55
  * @cssproperty --zn-input-* - Inherited input component CSS custom properties.
56
+ * @cssproperty --zn-datepicker-inline-border-color - Border colour of the calendar in `inline` mode.
56
57
  */
57
58
  export default class ZnDatepicker extends ZincElement implements ZincFormControl {
58
59
  static styles: CSSResultGroup = [unsafeCSS(formControlStyles), unsafeCSS(styles)];
@@ -120,6 +121,9 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
120
121
 
121
122
  @property({type: Boolean, reflect: true}) flush = false;
122
123
 
124
+ /** Renders the calendar in place instead of a popup, hiding the text input. */
125
+ @property({type: Boolean, reflect: true}) inline = false;
126
+
123
127
  /** Makes the input a required field. */
124
128
  @property({type: Boolean, reflect: true}) required = false;
125
129
 
@@ -232,6 +236,7 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
232
236
  @watch('disablePastDates', {waitUntilFirstUpdate: true})
233
237
  @watch('timePicker', {waitUntilFirstUpdate: true})
234
238
  @watch('timeFormat', {waitUntilFirstUpdate: true})
239
+ @watch('inline', {waitUntilFirstUpdate: true})
235
240
  handleDatepickerOptionsChange() {
236
241
  this.init();
237
242
  }
@@ -285,6 +290,7 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
285
290
 
286
291
  const options: AirDatepickerOptions = {
287
292
  locale: enLocale,
293
+ inline: this.inline,
288
294
  dateFormat: this.format,
289
295
  range: this.range,
290
296
  toggleSelected: false,
@@ -296,6 +302,10 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
296
302
  onSelect: ({date}) => {
297
303
  // Blur the input after selection to prevent invisible keyboard navigation
298
304
  if (this.timePicker || this.onlyTimepicker){
305
+ // An inline calendar never hides, so there's no later event to report the time on
306
+ if (this.inline) {
307
+ this.handleChange();
308
+ }
299
309
  return
300
310
  }
301
311
  if (date && !this.range) {
@@ -333,6 +343,10 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
333
343
  options.buttons = ['clear'];
334
344
  }
335
345
 
346
+ if (this.inline) {
347
+ this.adoptCalendarStyles(this.shadowRoot!);
348
+ }
349
+
336
350
  this._instance = new AirDatepicker(inputElement, options);
337
351
  }
338
352
  }
@@ -361,13 +375,20 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
361
375
 
362
376
  // Document-level calendar styles can't pierce a shadow root, so adopt them where needed
363
377
  const root = container.getRootNode();
364
- if (root instanceof ShadowRoot && !root.adoptedStyleSheets.includes(getAirDatepickerSheet())) {
365
- root.adoptedStyleSheets = [...root.adoptedStyleSheets, getAirDatepickerSheet()];
378
+ if (root instanceof ShadowRoot) {
379
+ this.adoptCalendarStyles(root);
366
380
  }
367
381
 
368
382
  return container;
369
383
  }
370
384
 
385
+ private adoptCalendarStyles(root: ShadowRoot) {
386
+ const sheet = getAirDatepickerSheet();
387
+ if (!root.adoptedStyleSheets.includes(sheet)) {
388
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
389
+ }
390
+ }
391
+
371
392
  /** Finds the closest `<dialog>` or popover ancestor, crossing shadow DOM boundaries. */
372
393
  private findTopLayerAncestor(): HTMLElement | null {
373
394
  let node: Node | null = this.assignedSlot ?? this.parentNode;
@@ -225,3 +225,28 @@
225
225
  margin-inline-end: 0;
226
226
  margin-inline-start: 0;
227
227
  }
228
+
229
+ // Inline mode keeps the input in the DOM to carry the value and format, but the
230
+ // calendar is inserted as its sibling - so the input chrome has to stop clipping
231
+ // and sizing the row
232
+ :host([inline]) .input {
233
+ display: block;
234
+ height: auto;
235
+ overflow: visible;
236
+ border: none;
237
+ border-radius: 0;
238
+ background: none;
239
+ box-shadow: none;
240
+ cursor: default;
241
+ }
242
+
243
+ // Lets a host that already draws a panel edge suppress the calendar's own
244
+ :host([inline]) .air-datepicker {
245
+ --adp-border-color-inline: var(--zn-datepicker-inline-border-color, rgb(var(--zn-border-color)));
246
+ }
247
+
248
+ :host([inline]) .input__control,
249
+ :host([inline]) .input__prefix,
250
+ :host([inline]) .input__suffix {
251
+ display: none;
252
+ }