snice 7.2.0 → 7.3.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/.agents/skills/snice/SKILL.md +40 -13
- package/README.md +6 -5
- package/adapters/react/SniceProvider.js +1 -1
- package/adapters/react/SniceRouter.d.ts +2 -0
- package/adapters/react/SniceRouter.js +3 -2
- package/adapters/react/SniceRouter.js.map +1 -1
- package/adapters/react/matchRoute.d.ts +4 -1
- package/adapters/react/matchRoute.js +13 -3
- package/adapters/react/matchRoute.js.map +1 -1
- package/adapters/react/useRequestHandler.js +1 -1
- package/bin/analyzer-contracts.js +2 -1
- package/bin/project-analyzer.js +302 -14
- package/bin/templates/AI_GUIDANCE.md +6 -1
- package/bin/templates/default/README.md +1 -1
- package/bin/templates/default/src/components/app-header.ts +11 -16
- package/bin/templates/default/src/components/notification-badge.ts +5 -14
- package/bin/templates/default/src/components/search-bar.ts +21 -65
- package/bin/templates/default/src/pages/dashboard.ts +15 -11
- package/bin/templates/default/src/pages/data.ts +10 -13
- package/bin/templates/default/src/pages/notifications.ts +4 -9
- package/bin/templates/default/src/pages/profile.ts +2 -8
- package/bin/templates/default/src/pages/settings.ts +16 -16
- package/dist/cdn/accordion/snice-accordion.js +1 -1
- package/dist/cdn/action-bar/snice-action-bar.js +1 -1
- package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
- package/dist/cdn/alert/snice-alert.js +1 -1
- package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
- package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
- package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
- package/dist/cdn/availability/snice-availability.js +1 -1
- package/dist/cdn/avatar/snice-avatar.js +1 -1
- package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
- package/dist/cdn/badge/snice-badge.js +1 -1
- package/dist/cdn/banner/snice-banner.js +1 -1
- package/dist/cdn/binpack/snice-binpack.js +1 -1
- package/dist/cdn/book/snice-book.js +1 -1
- package/dist/cdn/booking/snice-booking.js +1 -1
- package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
- package/dist/cdn/button/snice-button.js +1 -1
- package/dist/cdn/calendar/snice-calendar.js +1 -1
- package/dist/cdn/camera/snice-camera.js +1 -1
- package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
- package/dist/cdn/candlestick/snice-candlestick.js +1 -1
- package/dist/cdn/card/snice-card.js +1 -1
- package/dist/cdn/carousel/snice-carousel.js +1 -1
- package/dist/cdn/cart/snice-cart.js +1 -1
- package/dist/cdn/chart/snice-chart.js +1 -1
- package/dist/cdn/chat/snice-chat.js +1 -1
- package/dist/cdn/checkbox/snice-checkbox.js +1 -1
- package/dist/cdn/chip/snice-chip.js +1 -1
- package/dist/cdn/code-block/snice-code-block.js +1 -1
- package/dist/cdn/color-display/snice-color-display.js +1 -1
- package/dist/cdn/color-picker/snice-color-picker.js +1 -1
- package/dist/cdn/command-palette/snice-command-palette.js +1 -1
- package/dist/cdn/comments/snice-comments.js +1 -1
- package/dist/cdn/countdown/snice-countdown.js +1 -1
- package/dist/cdn/cropper/snice-cropper.js +1 -1
- package/dist/cdn/data-card/snice-data-card.js +1 -1
- package/dist/cdn/date-picker/snice-date-picker.js +1 -1
- package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
- package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
- package/dist/cdn/diff/snice-diff.js +1 -1
- package/dist/cdn/divider/snice-divider.js +1 -1
- package/dist/cdn/doc/snice-doc.js +1 -1
- package/dist/cdn/draw/snice-draw.js +1 -1
- package/dist/cdn/drawer/snice-drawer.js +1 -1
- package/dist/cdn/empty-state/snice-empty-state.js +1 -1
- package/dist/cdn/estimate/snice-estimate.js +1 -1
- package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
- package/dist/cdn/file-upload/snice-file-upload.js +1 -1
- package/dist/cdn/flip-card/snice-flip-card.js +1 -1
- package/dist/cdn/flow/snice-flow.js +1 -1
- package/dist/cdn/form-layout/snice-form-layout.js +1 -1
- package/dist/cdn/funnel/snice-funnel.js +1 -1
- package/dist/cdn/gantt/snice-gantt.js +1 -1
- package/dist/cdn/gauge/snice-gauge.js +1 -1
- package/dist/cdn/grid/snice-grid.js +1 -1
- package/dist/cdn/heatmap/snice-heatmap.js +1 -1
- package/dist/cdn/image/snice-image.js +1 -1
- package/dist/cdn/input/snice-input.js +1 -1
- package/dist/cdn/invoice/snice-invoice.js +1 -1
- package/dist/cdn/kanban/snice-kanban.js +1 -1
- package/dist/cdn/key-value/snice-key-value.js +1 -1
- package/dist/cdn/kpi/snice-kpi.js +1 -1
- package/dist/cdn/layout/snice-layout.js +1 -1
- package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
- package/dist/cdn/link/snice-link.js +1 -1
- package/dist/cdn/link-preview/snice-link-preview.js +1 -1
- package/dist/cdn/list/snice-list.js +1 -1
- package/dist/cdn/location/snice-location.js +1 -1
- package/dist/cdn/login/snice-login.js +1 -1
- package/dist/cdn/map/snice-map.js +1 -1
- package/dist/cdn/markdown/snice-markdown.js +1 -1
- package/dist/cdn/masonry/snice-masonry.js +1 -1
- package/dist/cdn/menu/snice-menu.js +1 -1
- package/dist/cdn/message-strip/snice-message-strip.js +1 -1
- package/dist/cdn/modal/snice-modal.js +1 -1
- package/dist/cdn/music-player/snice-music-player.js +1 -1
- package/dist/cdn/nav/snice-nav.js +1 -1
- package/dist/cdn/network-graph/snice-network-graph.js +1 -1
- package/dist/cdn/notification-center/snice-notification-center.js +1 -1
- package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
- package/dist/cdn/org-chart/snice-org-chart.js +1 -1
- package/dist/cdn/pagination/snice-pagination.js +1 -1
- package/dist/cdn/paint/snice-paint.js +1 -1
- package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
- package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
- package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
- package/dist/cdn/popover/snice-popover.js +1 -1
- package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
- package/dist/cdn/product-card/snice-product-card.js +1 -1
- package/dist/cdn/progress/snice-progress.js +1 -1
- package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
- package/dist/cdn/qr-code/snice-qr-code.js +1 -1
- package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
- package/dist/cdn/radio/snice-radio.js +1 -1
- package/dist/cdn/range-slider/snice-range-slider.js +1 -1
- package/dist/cdn/rating/snice-rating.js +1 -1
- package/dist/cdn/receipt/snice-receipt.js +1 -1
- package/dist/cdn/recipe/snice-recipe.js +1 -1
- package/dist/cdn/runtime/README.md +2 -2
- package/dist/cdn/runtime/snice-runtime.esm.js +28 -8
- package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
- package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.js +28 -8
- package/dist/cdn/runtime/snice-runtime.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.min.js +1 -1
- package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
- package/dist/cdn/sankey/snice-sankey.js +1 -1
- package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
- package/dist/cdn/select/snice-select.js +1 -1
- package/dist/cdn/skeleton/snice-skeleton.js +1 -1
- package/dist/cdn/slider/snice-slider.js +1 -1
- package/dist/cdn/sortable/snice-sortable.js +1 -1
- package/dist/cdn/sparkline/snice-sparkline.js +1 -1
- package/dist/cdn/spinner/snice-spinner.js +1 -1
- package/dist/cdn/split-button/snice-split-button.js +1 -1
- package/dist/cdn/split-pane/snice-split-pane.js +1 -1
- package/dist/cdn/spotlight/snice-spotlight.js +1 -1
- package/dist/cdn/stat-group/snice-stat-group.js +1 -1
- package/dist/cdn/step-input/snice-step-input.js +1 -1
- package/dist/cdn/stepper/snice-stepper.js +1 -1
- package/dist/cdn/switch/snice-switch.js +1 -1
- package/dist/cdn/table/snice-table.esm.js +1 -1
- package/dist/cdn/table/snice-table.js +1 -1
- package/dist/cdn/tabs/snice-tabs.js +1 -1
- package/dist/cdn/tag/snice-tag.js +1 -1
- package/dist/cdn/tag-input/snice-tag-input.js +1 -1
- package/dist/cdn/terminal/snice-terminal.js +1 -1
- package/dist/cdn/testimonial/snice-testimonial.js +1 -1
- package/dist/cdn/textarea/snice-textarea.js +1 -1
- package/dist/cdn/time-picker/snice-time-picker.js +1 -1
- package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
- package/dist/cdn/timeline/snice-timeline.js +1 -1
- package/dist/cdn/timer/snice-timer.js +1 -1
- package/dist/cdn/toast/snice-toast.js +1 -1
- package/dist/cdn/tooltip/snice-tooltip.js +1 -1
- package/dist/cdn/tree/snice-tree.js +1 -1
- package/dist/cdn/treemap/snice-treemap.js +1 -1
- package/dist/cdn/user-card/snice-user-card.js +1 -1
- package/dist/cdn/video-player/snice-video-player.js +1 -1
- package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
- package/dist/cdn/waterfall/snice-waterfall.js +1 -1
- package/dist/cdn/weather/snice-weather.js +1 -1
- package/dist/cdn/work-order/snice-work-order.js +1 -1
- package/dist/index.cjs +25 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +25 -5
- package/dist/index.esm.js.map +1 -1
- package/dist/index.iife.js +25 -5
- package/dist/index.iife.js.map +1 -1
- package/dist/react/SniceProvider.js +1 -1
- package/dist/react/SniceRouter.d.ts +2 -0
- package/dist/react/SniceRouter.js +3 -2
- package/dist/react/SniceRouter.js.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/matchRoute.d.ts +4 -1
- package/dist/react/matchRoute.js +13 -3
- package/dist/react/matchRoute.js.map +1 -1
- package/dist/react/useRequestHandler.js +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/dist/types/page-options.d.ts +15 -1
- package/dist/types/router-instance.d.ts +2 -1
- package/docs/ai/README.md +6 -4
- package/docs/ai/api.md +1 -1
- package/docs/ai/architecture.md +21 -8
- package/docs/ai/cli.md +4 -1
- package/docs/ai/controllers.md +11 -1
- package/docs/ai/decorators.md +1 -1
- package/docs/ai/react-integration.md +1 -0
- package/docs/ai/routing.md +25 -3
- package/docs/cli.md +4 -2
- package/docs/controllers.md +16 -4
- package/docs/react-integration.md +1 -0
- package/docs/routing.md +29 -2
- package/package.json +1 -1
package/bin/project-analyzer.js
CHANGED
|
@@ -426,6 +426,162 @@ const RULE_DEFINITIONS = [
|
|
|
426
426
|
}
|
|
427
427
|
}
|
|
428
428
|
},
|
|
429
|
+
{
|
|
430
|
+
id: 'snice/light-render-root-with-styles',
|
|
431
|
+
severity: 'suggestion',
|
|
432
|
+
category: 'architecture',
|
|
433
|
+
description: 'Ask for an explicit integration reason before a styled element gives up shadow-root encapsulation.',
|
|
434
|
+
check(context) {
|
|
435
|
+
if (isFrameworkImplementation(context.filename) || isTestFilename(context.filename)) return;
|
|
436
|
+
for (const declaration of findDecoratedClasses(context.source, 'element')) {
|
|
437
|
+
const header = context.source.slice(declaration.index, declaration.bodyStart);
|
|
438
|
+
const option = /\b(?:renderRoot\s*:\s*['"]light['"]|shadow\s*:\s*false)/.exec(header);
|
|
439
|
+
if (!option || !/(?:@styles\b|\bstatic\s+styles\b)/.test(declaration.body)) continue;
|
|
440
|
+
context.report(declaration.index + option.index, {
|
|
441
|
+
message: `${declaration.name} opts into light DOM despite owning component styles, so selectors and internals are no longer encapsulated.`,
|
|
442
|
+
fix: "Use the default shadow render root. Keep renderRoot: 'light' only for a documented integration requirement such as native parser parentage or intentional global-CSS participation."
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: 'snice/translator-controller',
|
|
449
|
+
severity: 'suggestion',
|
|
450
|
+
category: 'architecture',
|
|
451
|
+
description: 'Recommend keeping internal-event translation in the element contract instead of a controller.',
|
|
452
|
+
check(context) {
|
|
453
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
454
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
455
|
+
const onMethods = findDecoratedClassMethods(declaration.body, 'on');
|
|
456
|
+
const dispatchMethods = findDecoratedClassMethods(declaration.body, 'dispatch');
|
|
457
|
+
if (!onMethods.length || !dispatchMethods.length) continue;
|
|
458
|
+
const dispatchNames = new Set(dispatchMethods.map(method => method.name));
|
|
459
|
+
const translationMethods = new Set([
|
|
460
|
+
...onMethods.map(method => method.name),
|
|
461
|
+
...dispatchNames,
|
|
462
|
+
'attach',
|
|
463
|
+
'detach'
|
|
464
|
+
]);
|
|
465
|
+
const translatesEveryHandler = onMethods.every(method =>
|
|
466
|
+
[...dispatchNames].some(name => new RegExp(`\\bthis\\.${escapeRegExp(name)}\\s*\\(`).test(method.body)) ||
|
|
467
|
+
/\bthis\.element(?:\?\.|\.)dispatchEvent\s*\(/.test(method.body)
|
|
468
|
+
);
|
|
469
|
+
const ownsState = [...inspectElementMembers(declaration.body).fields]
|
|
470
|
+
.some(name => name !== 'element');
|
|
471
|
+
const ownsOtherMethods = findClassMethods(declaration.body)
|
|
472
|
+
.some(method => !translationMethods.has(method.name));
|
|
473
|
+
const hasExternalBehavior = /\b(?:fetch|setTimeout|setInterval)\s*\(|\bnew\s+(?:WebSocket|EventSource|Worker)\s*\(|\b(?:localStorage|sessionStorage|indexedDB|caches)\b|@(request|respond|state|property)\b/.test(declaration.body);
|
|
474
|
+
if (!translatesEveryHandler || ownsState || ownsOtherMethods || hasExternalBehavior) continue;
|
|
475
|
+
context.report(declaration.index, {
|
|
476
|
+
message: `${declaration.name} only translates element events into other element events and owns no external behavior or state.`,
|
|
477
|
+
fix: 'Put visual behavior, internal-part handling, and semantic event dispatch in the element itself. Use a controller only for application behavior specific to a set of elements.'
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
id: 'snice/controller-event-origin',
|
|
484
|
+
severity: 'warning',
|
|
485
|
+
category: 'events',
|
|
486
|
+
description: 'Require shared controllers to distinguish their host event from the same event bubbling out of nested hosts.',
|
|
487
|
+
check(context) {
|
|
488
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
489
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
490
|
+
const dispatched = new Set(
|
|
491
|
+
findDecoratedClassMethods(declaration.body, 'dispatch')
|
|
492
|
+
.flatMap(method => firstDecoratorStringArguments(method.decoratorArguments))
|
|
493
|
+
);
|
|
494
|
+
if (!dispatched.size) continue;
|
|
495
|
+
for (const handler of findDecoratedClassMethods(declaration.body, 'on')) {
|
|
496
|
+
const listened = firstDecoratorStringArguments(handler.decoratorArguments);
|
|
497
|
+
const onArguments = splitTopLevelArguments(handler.decoratorArguments);
|
|
498
|
+
const hasSelector = /^\s*(['"])[\s\S]*\1\s*$/.test(onArguments[1] ?? '');
|
|
499
|
+
if (hasSelector || !listened.some(event => dispatched.has(event))) continue;
|
|
500
|
+
const parameter = /^\s*([A-Za-z_$][\w$]*)/.exec(handler.parameters)?.[1];
|
|
501
|
+
if (!parameter || hasHostOriginCheck(handler.body, parameter)) continue;
|
|
502
|
+
const event = listened.find(name => dispatched.has(name));
|
|
503
|
+
context.report(declaration.bodyStart + handler.decoratorIndex, {
|
|
504
|
+
message: `${declaration.name} both listens for and dispatches "${event}" without checking that the event originated on its own host.`,
|
|
505
|
+
fix: `Guard the handler with if (${parameter}.target !== this.element) return; so the same event from a nested controller host is not handled twice.`
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
id: 'snice/controller-owns-routing',
|
|
513
|
+
severity: 'warning',
|
|
514
|
+
category: 'architecture',
|
|
515
|
+
description: 'Keep route parsing and navigation in routed pages instead of attached controllers.',
|
|
516
|
+
check(context) {
|
|
517
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
518
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
519
|
+
const routing = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.(?:search|hash)\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(declaration.body);
|
|
520
|
+
if (!routing) continue;
|
|
521
|
+
context.report(declaration.bodyStart + routing.index, {
|
|
522
|
+
message: `${declaration.name} owns URL parsing or navigation even though routing is page orchestration.`,
|
|
523
|
+
fix: 'Declare route/query parameters in @page({ routes }), receive them as page properties, and navigate through the Router. Keep the controller element-scoped.'
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: 'snice/self-ready-await',
|
|
530
|
+
severity: 'error',
|
|
531
|
+
category: 'lifecycle',
|
|
532
|
+
description: 'Reject awaiting an element\'s own ready promise from one of its @ready handlers.',
|
|
533
|
+
check(context) {
|
|
534
|
+
for (const declaration of findSniceElementClasses(context.source)) {
|
|
535
|
+
for (const method of findDecoratedClassMethods(declaration.body, 'ready', declaration.bodyStart)) {
|
|
536
|
+
const wait = /\bawait\s+(?:Promise\.resolve\s*\(\s*)?this\.ready\b/.exec(method.body);
|
|
537
|
+
if (!wait) continue;
|
|
538
|
+
context.report(method.bodyStart + wait.index, {
|
|
539
|
+
message: `${declaration.name}.${method.name} awaits this.ready from inside @ready, so each side waits for the other and initialization cannot complete.`,
|
|
540
|
+
fix: 'Remove the self-wait. @ready already runs after the first render; await a child element\'s ready/rendered promise when child readiness is what the code needs.'
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
id: 'snice/stray-probe-test',
|
|
548
|
+
severity: 'warning',
|
|
549
|
+
category: 'testing',
|
|
550
|
+
description: 'Reject diagnostic test files whose assertions cannot fail.',
|
|
551
|
+
check(context) {
|
|
552
|
+
if (!isTestFilename(context.filename)) return;
|
|
553
|
+
const code = blankStringContents(context.source);
|
|
554
|
+
for (const match of code.matchAll(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g)) {
|
|
555
|
+
context.report(match.index, {
|
|
556
|
+
message: 'This test assertion is always true and looks like an abandoned diagnostic probe.',
|
|
557
|
+
fix: 'Delete the probe file or replace the assertion with an observable behavior or contract that can fail.'
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'snice/test-helper-value-without-event',
|
|
564
|
+
severity: 'suggestion',
|
|
565
|
+
category: 'testing',
|
|
566
|
+
description: 'Warn when a test helper that appears to simulate user input only writes a DOM property.',
|
|
567
|
+
check(context) {
|
|
568
|
+
if (!isTestFilename(context.filename)) return;
|
|
569
|
+
for (const helper of findNamedFunctionBodies(context.source)) {
|
|
570
|
+
if (!/^(?:(?:type|enter|fill|change|check|uncheck|toggle|select|edit|input)|set(?:Input|Field|Control))/i.test(helper.name)) continue;
|
|
571
|
+
const write = /\b([A-Za-z_$][\w$]*)\s*\.\s*(value|checked)\s*=(?!=|>)/.exec(helper.body);
|
|
572
|
+
if (!write) continue;
|
|
573
|
+
const target = escapeRegExp(write[1]);
|
|
574
|
+
const userSignal = new RegExp(
|
|
575
|
+
`\\b${target}\\s*\\.\\s*(?:dispatchEvent|click)\\s*\\(|\\b(?:fireEvent|userEvent)\\b|\\b(?:fill|check|uncheck|selectOption)\\s*\\(`
|
|
576
|
+
);
|
|
577
|
+
if (userSignal.test(helper.body)) continue;
|
|
578
|
+
context.report(helper.bodyStart + write.index, {
|
|
579
|
+
message: `${helper.name}() writes .${write[2]} but emits no event, so it changes mechanism state without simulating a user action.`,
|
|
580
|
+
fix: 'Dispatch the control\'s documented input/change event after the write, or use the test runner\'s user-interaction helper. Keep direct writes only in tests of the property API itself.'
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
429
585
|
{
|
|
430
586
|
id: 'snice/observe-target',
|
|
431
587
|
severity: 'error',
|
|
@@ -1245,10 +1401,26 @@ const RULE_DEFINITIONS = [
|
|
|
1245
1401
|
check() {}
|
|
1246
1402
|
},
|
|
1247
1403
|
{
|
|
1248
|
-
id: 'snice/recommend-page-
|
|
1404
|
+
id: 'snice/recommend-page-decomposition',
|
|
1249
1405
|
severity: 'suggestion',
|
|
1250
1406
|
category: 'architecture',
|
|
1251
|
-
description: 'Recommend
|
|
1407
|
+
description: 'Recommend decomposing an oversized page without attaching a controller to the page host.',
|
|
1408
|
+
projectOnly: true,
|
|
1409
|
+
check() {}
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
id: 'snice/controller-on-page-host',
|
|
1413
|
+
severity: 'warning',
|
|
1414
|
+
category: 'architecture',
|
|
1415
|
+
description: 'Warn when a routed page imperatively attaches a controller to itself.',
|
|
1416
|
+
projectOnly: true,
|
|
1417
|
+
check() {}
|
|
1418
|
+
},
|
|
1419
|
+
{
|
|
1420
|
+
id: 'snice/recommend-route-params',
|
|
1421
|
+
severity: 'suggestion',
|
|
1422
|
+
category: 'router',
|
|
1423
|
+
description: 'Recommend declarative page route/query parameters instead of manual URL parsing and history writes.',
|
|
1252
1424
|
projectOnly: true,
|
|
1253
1425
|
check() {}
|
|
1254
1426
|
},
|
|
@@ -1403,8 +1575,16 @@ export function analyzeProject(files) {
|
|
|
1403
1575
|
continue;
|
|
1404
1576
|
}
|
|
1405
1577
|
const analyzable = maskComments(file.source);
|
|
1578
|
+
const staleGuardOwners = ['page', 'element', 'controller'].flatMap(kind =>
|
|
1579
|
+
findDecoratedClasses(analyzable, kind).map(declaration => ({
|
|
1580
|
+
kind,
|
|
1581
|
+
start: declaration.bodyStart,
|
|
1582
|
+
end: declaration.bodyStart + declaration.body.length
|
|
1583
|
+
}))
|
|
1584
|
+
);
|
|
1406
1585
|
for (const guard of analyzable.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\+\+this\.([A-Za-z_$][\w$]*(?:Id|Version|Token|Generation))\b([\s\S]{0,2000}?)\b(?:\1\s*!==?\s*this\.\2|this\.\2\s*!==?\s*\1)/g)) {
|
|
1407
|
-
|
|
1586
|
+
const owner = staleGuardOwners.find(candidate => guard.index >= candidate.start && guard.index < candidate.end);
|
|
1587
|
+
staleGuards.push({ file, index: guard.index, field: guard[2], owner: owner?.kind });
|
|
1408
1588
|
}
|
|
1409
1589
|
const provenance = buildSourceProvenance(analyzable);
|
|
1410
1590
|
routerConstructions.push(
|
|
@@ -1657,6 +1837,38 @@ export function analyzeProject(files) {
|
|
|
1657
1837
|
if (!declarationsByClass.has(key)) declarationsByClass.set(key, []);
|
|
1658
1838
|
declarationsByClass.get(key).push(declaration);
|
|
1659
1839
|
}
|
|
1840
|
+
|
|
1841
|
+
for (const page of pageClasses) {
|
|
1842
|
+
const selfAttachment = /\battachController\s*\(\s*this\s*,|\bthis\.controller\s*=/.exec(page.body);
|
|
1843
|
+
if (selfAttachment) {
|
|
1844
|
+
const index = page.bodyStart + selfAttachment.index;
|
|
1845
|
+
const location = sourceLocation(page.file.source, index);
|
|
1846
|
+
diagnostics.push({
|
|
1847
|
+
severity: 'warning',
|
|
1848
|
+
ruleId: 'snice/controller-on-page-host',
|
|
1849
|
+
message: `${page.name} attaches a controller to its own page host, coupling two lifecycle owners for one route.`,
|
|
1850
|
+
fix: 'Keep element orchestration in the page. Put application behavior specific to a set of elements in a controller and bind it where the page composes those elements. A host-free reusable function may stay a plain module.',
|
|
1851
|
+
file: page.file.filename,
|
|
1852
|
+
line: location.line,
|
|
1853
|
+
column: location.column
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const manualRoute = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.search\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(page.body);
|
|
1858
|
+
if (manualRoute) {
|
|
1859
|
+
const index = page.bodyStart + manualRoute.index;
|
|
1860
|
+
const location = sourceLocation(page.file.source, index);
|
|
1861
|
+
diagnostics.push({
|
|
1862
|
+
severity: 'suggestion',
|
|
1863
|
+
ruleId: 'snice/recommend-route-params',
|
|
1864
|
+
message: `${page.name} parses or writes URL state manually even though route/query parameters can be declared by the page.`,
|
|
1865
|
+
fix: "Declare parameters in @page({ routes: ['/path?q=:query'] }), receive them as page properties before @ready, and call the Router's navigate(path) to write route state. Review docs/ai/routing.md.",
|
|
1866
|
+
file: page.file.filename,
|
|
1867
|
+
line: location.line,
|
|
1868
|
+
column: location.column
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1660
1872
|
for (const [key, declarations] of declarationsByClass) {
|
|
1661
1873
|
const page = declarations.find(declaration => declaration.decorator === 'page');
|
|
1662
1874
|
const element = declarations.find(declaration => declaration.decorator === 'element');
|
|
@@ -1751,14 +1963,15 @@ export function analyzeProject(files) {
|
|
|
1751
1963
|
}
|
|
1752
1964
|
}
|
|
1753
1965
|
|
|
1754
|
-
|
|
1755
|
-
|
|
1966
|
+
const repeatedStaleGuards = new Set(staleGuards.map(guard => guard.file.filename)).size >= 2;
|
|
1967
|
+
if (repeatedStaleGuards) {
|
|
1968
|
+
for (const guard of staleGuards.filter(candidate => candidate.owner)) {
|
|
1756
1969
|
const location = sourceLocation(guard.file.source, guard.index);
|
|
1757
1970
|
diagnostics.push({
|
|
1758
1971
|
severity: 'suggestion',
|
|
1759
1972
|
ruleId: 'snice/duplicated-stale-guard',
|
|
1760
1973
|
message: `A ${guard.field} stale-response guard is repeated across project files.`,
|
|
1761
|
-
fix: 'Extract
|
|
1974
|
+
fix: 'Extract host-free request sequencing into a plain module in the project\'s chosen location. Keep element-specific application behavior in controllers and element orchestration in pages. Review docs/ai/architecture.md.',
|
|
1762
1975
|
file: guard.file.filename,
|
|
1763
1976
|
line: location.line,
|
|
1764
1977
|
column: location.column
|
|
@@ -1766,21 +1979,21 @@ export function analyzeProject(files) {
|
|
|
1766
1979
|
}
|
|
1767
1980
|
}
|
|
1768
1981
|
|
|
1769
|
-
const
|
|
1982
|
+
const pageDecompositionReported = new Set();
|
|
1770
1983
|
for (const page of pageClasses) {
|
|
1771
1984
|
const profile = pageLogicProfile(page.body);
|
|
1772
1985
|
if (!profile.recommend) continue;
|
|
1773
1986
|
const location = sourceLocation(page.file.source, page.index);
|
|
1774
1987
|
diagnostics.push({
|
|
1775
1988
|
severity: 'suggestion',
|
|
1776
|
-
ruleId: 'snice/recommend-page-
|
|
1989
|
+
ruleId: 'snice/recommend-page-decomposition',
|
|
1777
1990
|
message: `${page.name} contains substantial non-visual logic (${profile.effectfulMethods} effectful methods, ${profile.effects} external effects, ${profile.decisions} decision points).`,
|
|
1778
|
-
fix:
|
|
1991
|
+
fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in the page. A host-free reusable function may stay a plain module. Review docs/ai/architecture.md.',
|
|
1779
1992
|
file: page.file.filename,
|
|
1780
1993
|
line: location.line,
|
|
1781
1994
|
column: location.column
|
|
1782
1995
|
});
|
|
1783
|
-
|
|
1996
|
+
pageDecompositionReported.add(`${page.file.filename}:${page.index}`);
|
|
1784
1997
|
}
|
|
1785
1998
|
|
|
1786
1999
|
const repeatedPageMethods = new Map();
|
|
@@ -1797,18 +2010,18 @@ export function analyzeProject(files) {
|
|
|
1797
2010
|
if (files.length < 2) continue;
|
|
1798
2011
|
for (const { page, method } of occurrences) {
|
|
1799
2012
|
const reportKey = `${page.file.filename}:${page.index}`;
|
|
1800
|
-
if (
|
|
2013
|
+
if (pageDecompositionReported.has(reportKey)) continue;
|
|
1801
2014
|
const location = sourceLocation(page.file.source, method.index);
|
|
1802
2015
|
diagnostics.push({
|
|
1803
2016
|
severity: 'suggestion',
|
|
1804
|
-
ruleId: 'snice/recommend-page-
|
|
2017
|
+
ruleId: 'snice/recommend-page-decomposition',
|
|
1805
2018
|
message: `${method.name}() repeats substantial non-visual logic across ${files.length} page files.`,
|
|
1806
|
-
fix:
|
|
2019
|
+
fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in pages. Extract this host-free reusable operation into a plain module in the project\'s chosen location. Review docs/ai/architecture.md.',
|
|
1807
2020
|
file: page.file.filename,
|
|
1808
2021
|
line: location.line,
|
|
1809
2022
|
column: location.column
|
|
1810
2023
|
});
|
|
1811
|
-
|
|
2024
|
+
pageDecompositionReported.add(reportKey);
|
|
1812
2025
|
}
|
|
1813
2026
|
}
|
|
1814
2027
|
|
|
@@ -2162,10 +2375,12 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2162
2375
|
const pattern = new RegExp(`@${escapeRegExp(decoratorName)}\\b`, 'g');
|
|
2163
2376
|
for (const match of code.matchAll(pattern)) {
|
|
2164
2377
|
let cursor = match.index + match[0].length;
|
|
2378
|
+
let decoratorArguments = '';
|
|
2165
2379
|
while (/\s/.test(code[cursor] ?? '')) cursor++;
|
|
2166
2380
|
if (code[cursor] === '(') {
|
|
2167
2381
|
const close = findMatchingDelimiter(code, cursor, '(', ')');
|
|
2168
2382
|
if (close < 0) continue;
|
|
2383
|
+
decoratorArguments = body.slice(cursor + 1, close);
|
|
2169
2384
|
cursor = close + 1;
|
|
2170
2385
|
}
|
|
2171
2386
|
const signature = /^\s*(?:(?:public|private|protected|static|override)\s+)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(code.slice(cursor));
|
|
@@ -2179,6 +2394,9 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2179
2394
|
if (methodClose < 0) continue;
|
|
2180
2395
|
methods.push({
|
|
2181
2396
|
name: signature[1],
|
|
2397
|
+
decoratorIndex: match.index,
|
|
2398
|
+
decoratorArguments,
|
|
2399
|
+
parameters: body.slice(parametersOpen + 1, parametersClose),
|
|
2182
2400
|
bodyStart: bodyStart + methodOpen + 1,
|
|
2183
2401
|
body: body.slice(methodOpen + 1, methodClose)
|
|
2184
2402
|
});
|
|
@@ -2186,6 +2404,55 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2186
2404
|
return methods;
|
|
2187
2405
|
}
|
|
2188
2406
|
|
|
2407
|
+
function firstDecoratorStringArguments(argumentsSource) {
|
|
2408
|
+
const first = /^\s*(['"])([^'"]+)\1/.exec(argumentsSource);
|
|
2409
|
+
if (first) return [first[2]];
|
|
2410
|
+
const array = /^\s*\[([^\]]*)\]/.exec(argumentsSource);
|
|
2411
|
+
if (!array) return [];
|
|
2412
|
+
return [...array[1].matchAll(/(['"])([^'"]+)\1/g)].map(match => match[2]);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function splitTopLevelArguments(source) {
|
|
2416
|
+
const argumentsList = [];
|
|
2417
|
+
let start = 0;
|
|
2418
|
+
let quote = '';
|
|
2419
|
+
let escaped = false;
|
|
2420
|
+
let round = 0;
|
|
2421
|
+
let square = 0;
|
|
2422
|
+
let curly = 0;
|
|
2423
|
+
for (let index = 0; index < source.length; index++) {
|
|
2424
|
+
const character = source[index];
|
|
2425
|
+
if (quote) {
|
|
2426
|
+
if (escaped) escaped = false;
|
|
2427
|
+
else if (character === '\\') escaped = true;
|
|
2428
|
+
else if (character === quote) quote = '';
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
if (character === '"' || character === "'" || character === '`') {
|
|
2432
|
+
quote = character;
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
if (character === '(') round++;
|
|
2436
|
+
else if (character === ')') round = Math.max(0, round - 1);
|
|
2437
|
+
else if (character === '[') square++;
|
|
2438
|
+
else if (character === ']') square = Math.max(0, square - 1);
|
|
2439
|
+
else if (character === '{') curly++;
|
|
2440
|
+
else if (character === '}') curly = Math.max(0, curly - 1);
|
|
2441
|
+
else if (character === ',' && round === 0 && square === 0 && curly === 0) {
|
|
2442
|
+
argumentsList.push(source.slice(start, index).trim());
|
|
2443
|
+
start = index + 1;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
argumentsList.push(source.slice(start).trim());
|
|
2447
|
+
return argumentsList;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function hasHostOriginCheck(body, parameter) {
|
|
2451
|
+
const target = `${escapeRegExp(parameter)}\\s*\\.\\s*target`;
|
|
2452
|
+
const host = 'this\\s*\\.\\s*element';
|
|
2453
|
+
return new RegExp(`(?:${target})\\s*[!=]==?\\s*(?:${host})|(?:${host})\\s*[!=]==?\\s*(?:${target})`).test(body);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2189
2456
|
function isDirectMemberWrite(body, index, length) {
|
|
2190
2457
|
const before = body.slice(Math.max(0, index - 3), index);
|
|
2191
2458
|
const after = body.slice(index + length).trimStart();
|
|
@@ -2218,6 +2485,23 @@ function findClassMethods(body, bodyStart = 0) {
|
|
|
2218
2485
|
return methods;
|
|
2219
2486
|
}
|
|
2220
2487
|
|
|
2488
|
+
function findNamedFunctionBodies(source) {
|
|
2489
|
+
const code = blankStringContents(maskComments(source));
|
|
2490
|
+
const functions = [];
|
|
2491
|
+
const pattern = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{]*\{|\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=\n]+)?=>\s*\{/g;
|
|
2492
|
+
for (const match of code.matchAll(pattern)) {
|
|
2493
|
+
const open = match.index + match[0].lastIndexOf('{');
|
|
2494
|
+
const close = findMatchingDelimiter(code, open, '{', '}');
|
|
2495
|
+
if (close < 0) continue;
|
|
2496
|
+
functions.push({
|
|
2497
|
+
name: match[1] ?? match[2],
|
|
2498
|
+
bodyStart: open + 1,
|
|
2499
|
+
body: source.slice(open + 1, close)
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
return functions;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2221
2505
|
function pageLogicProfile(body) {
|
|
2222
2506
|
const code = blankStringContents(body);
|
|
2223
2507
|
const methods = findClassMethods(body);
|
|
@@ -3101,6 +3385,10 @@ function isFrameworkImplementation(filename) {
|
|
|
3101
3385
|
return /(?:^|[/\\])packages[/\\]components[/\\]src(?:[/\\]|$)/.test(filename);
|
|
3102
3386
|
}
|
|
3103
3387
|
|
|
3388
|
+
function isTestFilename(filename) {
|
|
3389
|
+
return /(?:^|[/\\])[^/\\]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3104
3392
|
function sourceLocation(source, index) {
|
|
3105
3393
|
const prefix = source.slice(0, index);
|
|
3106
3394
|
const line = prefix.split('\n').length;
|
|
@@ -7,6 +7,12 @@ reviewing Snice code. It routes you to the version-matched references under
|
|
|
7
7
|
Do not infer Snice behavior from Lit or generic web-component conventions. Read
|
|
8
8
|
only the topical reference and component documents needed for the task.
|
|
9
9
|
|
|
10
|
+
Visual behavior belongs in elements. Application behavior specific to a set of
|
|
11
|
+
elements belongs in a controller. Element orchestration belongs in pages. Keep
|
|
12
|
+
those ownership rules separate from the attachment mechanism: do not attach a
|
|
13
|
+
controller to a page host. A host-free reusable function may stay a plain module
|
|
14
|
+
wherever the project convention puts it; state plus lifecycle belongs in a daemon.
|
|
15
|
+
|
|
10
16
|
Useful diagnostics:
|
|
11
17
|
|
|
12
18
|
```bash
|
|
@@ -15,4 +21,3 @@ npx snice check
|
|
|
15
21
|
|
|
16
22
|
Use `doctor` or `validate` only to isolate the configuration or source half of
|
|
17
23
|
a failed check.
|
|
18
|
-
|
|
@@ -61,7 +61,7 @@ src/
|
|
|
61
61
|
notifications.ts # Live feed, filters, @watch, keyboard shortcuts
|
|
62
62
|
settings.ts # Theme toggle, form bindings, @context, Ctrl+S save
|
|
63
63
|
data.ts # Data table, search-bar, @observe('resize'), filters
|
|
64
|
-
services/ #
|
|
64
|
+
services/ # This project's host-free functions (auth, storage, jwt)
|
|
65
65
|
middleware/ # Fetch middleware (auth, error, retry)
|
|
66
66
|
daemons/ # Lifecycle-managed classes (notifications WebSocket)
|
|
67
67
|
guards/ # Route guards (auth)
|
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
import { element,
|
|
1
|
+
import { element, render, styles, context, on, dispatch, dispose, html, css, state } from 'snice';
|
|
2
2
|
import type { Context } from 'snice';
|
|
3
3
|
import type { ApplicationContext } from '../context';
|
|
4
4
|
import { logout } from '../services/auth';
|
|
5
5
|
|
|
6
6
|
@element('app-header')
|
|
7
7
|
export class AppHeader extends HTMLElement {
|
|
8
|
-
@
|
|
9
|
-
@
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
@
|
|
8
|
+
@state() userName = '';
|
|
9
|
+
@state() userAvatar = '';
|
|
10
|
+
@state() authenticated = false;
|
|
11
|
+
@state() menuOpen = false;
|
|
12
|
+
@state() notificationCount = 0;
|
|
13
13
|
private unsubscribeNotifications: (() => void) | null = null;
|
|
14
14
|
|
|
15
|
-
@query('.user-menu') $menu!: HTMLElement;
|
|
16
|
-
|
|
17
15
|
@context()
|
|
18
16
|
handleContext(ctx: Context) {
|
|
19
17
|
const { principal, notifications } = ctx.application as ApplicationContext;
|
|
@@ -37,13 +35,6 @@ export class AppHeader extends HTMLElement {
|
|
|
37
35
|
}
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
@watch('menuOpen')
|
|
41
|
-
onMenuToggle() {
|
|
42
|
-
if (this.$menu) {
|
|
43
|
-
this.$menu.style.display = this.menuOpen ? 'block' : 'none';
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
38
|
toggleMenu() {
|
|
48
39
|
this.menuOpen = !this.menuOpen;
|
|
49
40
|
}
|
|
@@ -96,7 +87,7 @@ export class AppHeader extends HTMLElement {
|
|
|
96
87
|
<span class="user-name">${this.userName}</span>
|
|
97
88
|
</button>
|
|
98
89
|
|
|
99
|
-
<div class="user-menu"
|
|
90
|
+
<div class="user-menu" ?hidden=${!this.menuOpen}>
|
|
100
91
|
<a href="#/profile" @click=${() => this.menuOpen = false}>Profile</a>
|
|
101
92
|
<a href="#/settings" @click=${() => this.menuOpen = false}>Settings</a>
|
|
102
93
|
<snice-divider></snice-divider>
|
|
@@ -195,6 +186,10 @@ export class AppHeader extends HTMLElement {
|
|
|
195
186
|
z-index: 100;
|
|
196
187
|
}
|
|
197
188
|
|
|
189
|
+
.user-menu[hidden] {
|
|
190
|
+
display: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
198
193
|
.user-menu a,
|
|
199
194
|
.user-menu button {
|
|
200
195
|
display: block;
|
|
@@ -1,24 +1,15 @@
|
|
|
1
|
-
import { element, property, render, styles, html, css
|
|
1
|
+
import { element, property, render, styles, html, css } from 'snice';
|
|
2
2
|
|
|
3
3
|
@element('notification-badge')
|
|
4
4
|
export class NotificationBadge extends HTMLElement {
|
|
5
5
|
@property({ type: Number }) count = 0;
|
|
6
6
|
|
|
7
|
-
@
|
|
8
|
-
|
|
9
|
-
@watch('count')
|
|
10
|
-
onCountChange() {
|
|
11
|
-
if (this.$badge) {
|
|
12
|
-
this.$badge.textContent = this.count > 99 ? '99+' : String(this.count);
|
|
13
|
-
this.$badge.style.display = this.count > 0 ? 'flex' : 'none';
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
@render({ once: true })
|
|
7
|
+
@render()
|
|
18
8
|
renderContent() {
|
|
19
|
-
const display = this.count > 0 ? 'flex' : 'none';
|
|
20
9
|
return html`
|
|
21
|
-
<
|
|
10
|
+
<if ${this.count > 0}>
|
|
11
|
+
<span class="badge">${this.count > 99 ? '99+' : this.count}</span>
|
|
12
|
+
</if>
|
|
22
13
|
`;
|
|
23
14
|
}
|
|
24
15
|
|
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { debounce, dispatch, element, html, live, property, render, styles, css } from 'snice';
|
|
2
2
|
|
|
3
3
|
@element('search-bar')
|
|
4
4
|
export class SearchBar extends HTMLElement {
|
|
5
5
|
@property() value = '';
|
|
6
6
|
@property() placeholder = 'Search...';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
this.
|
|
11
|
-
this.dispatchSearch();
|
|
8
|
+
handleInput(e: CustomEvent<{ value: string }>) {
|
|
9
|
+
this.value = e.detail.value;
|
|
10
|
+
this.queueSearch();
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
clearSearch(e: Event) {
|
|
13
|
+
clearSearch() {
|
|
16
14
|
this.value = '';
|
|
17
|
-
(
|
|
15
|
+
this.dispatchSearch();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@debounce(300)
|
|
19
|
+
queueSearch() {
|
|
18
20
|
this.dispatchSearch();
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -26,17 +28,15 @@ export class SearchBar extends HTMLElement {
|
|
|
26
28
|
@render()
|
|
27
29
|
renderContent() {
|
|
28
30
|
return html`
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</if>
|
|
39
|
-
</div>
|
|
31
|
+
<snice-input
|
|
32
|
+
type="search"
|
|
33
|
+
clearable
|
|
34
|
+
prefix-icon="🔍"
|
|
35
|
+
.value=${live(this.value)}
|
|
36
|
+
placeholder="${this.placeholder}"
|
|
37
|
+
@input-input=${this.handleInput}
|
|
38
|
+
@keydown.escape=${this.clearSearch}
|
|
39
|
+
></snice-input>
|
|
40
40
|
`;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -47,52 +47,8 @@ export class SearchBar extends HTMLElement {
|
|
|
47
47
|
display: block;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
align-items: center;
|
|
53
|
-
gap: 0.5rem;
|
|
54
|
-
padding: 0.5rem 0.75rem;
|
|
55
|
-
background: var(--snice-color-surface);
|
|
56
|
-
border: 1px solid var(--snice-color-border);
|
|
57
|
-
border-radius: var(--snice-border-radius-lg);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
.search-container:focus-within {
|
|
61
|
-
border-color: var(--snice-color-primary);
|
|
62
|
-
box-shadow: 0 0 0 3px color-mix(in srgb, var(--snice-color-primary) 15%, transparent);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
.icon {
|
|
66
|
-
color: var(--snice-color-text-secondary);
|
|
67
|
-
font-size: 0.875rem;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
input {
|
|
71
|
-
flex: 1;
|
|
72
|
-
border: none;
|
|
73
|
-
outline: none;
|
|
74
|
-
background: transparent;
|
|
75
|
-
font-size: 0.875rem;
|
|
76
|
-
color: var(--snice-color-text);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
input::placeholder {
|
|
80
|
-
color: var(--snice-color-text-secondary);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.clear {
|
|
84
|
-
background: none;
|
|
85
|
-
border: none;
|
|
86
|
-
cursor: pointer;
|
|
87
|
-
color: var(--snice-color-text-secondary);
|
|
88
|
-
font-size: 0.75rem;
|
|
89
|
-
padding: 0.125rem 0.25rem;
|
|
90
|
-
border-radius: var(--snice-border-radius-md);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
.clear:hover {
|
|
94
|
-
background: var(--snice-color-surface-container-low);
|
|
95
|
-
color: var(--snice-color-text);
|
|
50
|
+
snice-input {
|
|
51
|
+
width: 100%;
|
|
96
52
|
}
|
|
97
53
|
`;
|
|
98
54
|
}
|